Synchronize hardening: added onboard purge, non-interactive mode, and diagnostic startup logs
This commit is contained in:
parent
9b15ff27de
commit
46dc6e5d5d
6 changed files with 766 additions and 16 deletions
363
TEAMS_ID_MAPPING_ANALYSIS.md
Normal file
363
TEAMS_ID_MAPPING_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
# Teams Channel Integration & ID Mapping Analysis
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Teams Channel Implementation Status**: ❌ **NOT YET IMPLEMENTED**
|
||||||
|
- Search results show no Teams/MSTeams channel in `pkg/channels/`
|
||||||
|
- Only reference found: migration config reference in `pkg/migrate/sources/openclaw/openclaw_config.go:123`
|
||||||
|
- **Foundry Integration**: Only implemented as an LLM **provider** (Azure AI Foundry), not as a channel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## InboundMessage Structure (Bus Layer)
|
||||||
|
|
||||||
|
**Location**: [pkg/bus/types.go](pkg/bus/types.go)
|
||||||
|
|
||||||
|
### Core Fields Available
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InboundMessage struct {
|
||||||
|
Channel string // Channel name (e.g., "teams", "slack", "telegram")
|
||||||
|
SenderID string // Platform-specific sender identifier
|
||||||
|
Sender SenderInfo // Structured sender information
|
||||||
|
ChatID string // Conversation/chat identifier (CRITICAL FOR ISOLATION)
|
||||||
|
Content string // Message text content
|
||||||
|
Media []string // Media references (attachments)
|
||||||
|
Peer Peer // Routing peer information
|
||||||
|
MessageID string // Platform-specific message ID
|
||||||
|
MediaScope string // Media lifecycle tracking scope
|
||||||
|
SessionKey string // Session key (optional, can be auto-resolved)
|
||||||
|
Metadata map[string]string // Platform-specific metadata
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SenderInfo Sub-structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SenderInfo struct {
|
||||||
|
Platform string // "telegram", "discord", "slack", "teams", etc.
|
||||||
|
PlatformID string // Raw platform ID (e.g., Teams UserID "29:...")
|
||||||
|
CanonicalID string // Normalized "platform:id" format (e.g., "teams:29:...")
|
||||||
|
Username string // Display username (e.g., "@alice")
|
||||||
|
DisplayName string // Full display name
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Peer Sub-structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Peer struct {
|
||||||
|
Kind string // "direct" | "group" | "channel" | ""
|
||||||
|
ID string // Peer identifier (user_id, group_id, channel_id, etc.)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ID Mapping for Hypothetical Teams Implementation
|
||||||
|
|
||||||
|
### What Teams Would Need to Provide
|
||||||
|
|
||||||
|
If Teams were to be integrated, the following IDs should map as follows:
|
||||||
|
|
||||||
|
| Teams ID | InboundMessage Field | Notes |
|
||||||
|
|----------|----------------------|-------|
|
||||||
|
| User ID (e.g., `29:1ABC123`) | `SenderID`, `Sender.PlatformID` | Teams uses format `29:uuid` |
|
||||||
|
| Conversation ID | `ChatID` | CRITICAL: Identifies conversation scope |
|
||||||
|
| Team ID | `Metadata["team_id"]`, potentially routing input | Can be used for team-level routing |
|
||||||
|
| Channel ID | `Peer.ID` (if channel) | When in Team channel |
|
||||||
|
| Service URL | `Metadata["service_url"]` | Teams service endpoint |
|
||||||
|
| Activity ID | `MessageID` | Platform message identifier |
|
||||||
|
|
||||||
|
### Canonical ID Format
|
||||||
|
|
||||||
|
**Pattern**: `platform:platform_id`
|
||||||
|
|
||||||
|
**Example for Teams**:
|
||||||
|
```
|
||||||
|
"teams:29:1ABC123" = Canonical ID for Teams user 29:1ABC123
|
||||||
|
```
|
||||||
|
|
||||||
|
Built via: [pkg/identity/identity.go](pkg/identity/identity.go)
|
||||||
|
```go
|
||||||
|
func BuildCanonicalID(platform, platformID string) string {
|
||||||
|
p := strings.ToLower(strings.TrimSpace(platform))
|
||||||
|
id := strings.TrimSpace(platformID)
|
||||||
|
if p == "" || id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p + ":" + id // "teams:29:abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ChatID Usage & Session Isolation
|
||||||
|
|
||||||
|
**Location**: [pkg/agent/loop.go](pkg/agent/loop.go#L1250-L1270)
|
||||||
|
|
||||||
|
### Current ChatID Role
|
||||||
|
|
||||||
|
The `ChatID` field is **THE PRIMARY KEY** for conversation isolation:
|
||||||
|
|
||||||
|
1. **Session Binding**: Each unique `ChatID` can map to a separate session depending on DMScope
|
||||||
|
2. **Workspace Isolation**: When non-empty and not "direct", creates isolated agent workspace:
|
||||||
|
```go
|
||||||
|
if isolationID != "" && isolationID != "direct" {
|
||||||
|
// Create transient isolated instance for this chat session
|
||||||
|
agent = NewAgentInstance(ac, cfg, baseAgent.Provider, isolationID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. **State Persistence**: Last ChatID tracked for workspace continuity
|
||||||
|
|
||||||
|
**Example mapping**:
|
||||||
|
- Single direct message with user → `ChatID = "teams:29:1ABC123"`
|
||||||
|
- Team channel conversation → `ChatID = "teams-channel:xyz789"`
|
||||||
|
- Group chat → `ChatID = "teams-groupchat:123abc"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Key Construction & Resolution
|
||||||
|
|
||||||
|
**Location**: [pkg/routing/session_key.go](pkg/routing/session_key.go) + [pkg/routing/route.go](pkg/routing/route.go)
|
||||||
|
|
||||||
|
### RouteInput (What Channel Provides to Router)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RouteInput struct {
|
||||||
|
Channel string // "teams" (if implemented)
|
||||||
|
AccountID string // Bot account/app ID
|
||||||
|
Peer *RoutePeer // Who message is from (user)
|
||||||
|
ParentPeer *RoutePeer // Parent context (e.g., Team)
|
||||||
|
GuildID string // Guild/workspace ID (if applicable)
|
||||||
|
TeamID string // Teams Team ID (would go here)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ResolvedRoute Output
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ResolvedRoute struct {
|
||||||
|
AgentID string // Which agent handles this message
|
||||||
|
SessionKey string // Session identifier pattern
|
||||||
|
MainSessionKey string // Main session fallback
|
||||||
|
MatchedBy string // How routing was matched
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Key Patterns
|
||||||
|
|
||||||
|
**DMScope** configuration determines how sessions are keyed:
|
||||||
|
|
||||||
|
| DMScope Mode | Format | Example | Use Case |
|
||||||
|
|--------------|--------|---------|----------|
|
||||||
|
| `DMScopeMain` | `agent:agentid:main` | `agent:teams-bot:main` | Single shared session |
|
||||||
|
| `DMScopePerPeer` | `agent:agentid:direct:peerid` | `agent:teams-bot:direct:user123` | Per-user sessions |
|
||||||
|
| `DMScopePerChannelPeer` | `agent:agentid:channel:direct:peerid` | `agent:teams-bot:teams:direct:user123` | Per-channel-per-user |
|
||||||
|
| `DMScopePerAccountChannelPeer` | `agent:agentid:channel:account:direct:peerid` | `agent:teams-bot:teams:acct1:direct:user123` | Per-account-channel-user |
|
||||||
|
|
||||||
|
**Location**: [pkg/routing/session_key.go:40-100](pkg/routing/session_key.go#L40-L100)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// For Teams direct message:
|
||||||
|
BuildAgentPeerSessionKey(SessionKeyParams{
|
||||||
|
AgentID: "teams-bot",
|
||||||
|
Channel: "teams",
|
||||||
|
AccountID: "bot-app-id",
|
||||||
|
Peer: &RoutePeer{Kind: "direct", ID: "29:abc123"},
|
||||||
|
DMScope: DMScopePerChannelPeer,
|
||||||
|
})
|
||||||
|
// Returns: "agent:teams-bot:teams:direct:29:abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ID Priority Cascade for Agent Routing
|
||||||
|
|
||||||
|
**Location**: [pkg/routing/route.go:68-126](pkg/routing/route.go#L68-L126)
|
||||||
|
|
||||||
|
The agent resolver uses this **7-level priority**:
|
||||||
|
|
||||||
|
1. **Peer binding** → Match on specific user/peer ID
|
||||||
|
2. **Parent peer binding** → Match on parent context (Team, Guild, etc.)
|
||||||
|
3. **Guild binding** → Match on Guild/Workspace ID
|
||||||
|
4. **Team binding** → Match on Team ID ← **TEAMS WOULD USE THIS**
|
||||||
|
5. **Account binding** → Match on account/app ID
|
||||||
|
6. **Channel wildcard** → Match on channel with wildcard
|
||||||
|
7. **Default agent** → Fallback
|
||||||
|
|
||||||
|
**For Teams, routing would likely use**:
|
||||||
|
- Level 2: ParentPeer = Team
|
||||||
|
- Level 3: GuildID = Team ID
|
||||||
|
- Level 4: TeamID = Team ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Foundry Integration Status
|
||||||
|
|
||||||
|
**Locations**:
|
||||||
|
- [pkg/providers/factory_provider.go:196](pkg/providers/factory_provider.go#L196)
|
||||||
|
- [pkg/providers/openai_compat/provider.go:432](pkg/providers/openai_compat/provider.go#L432)
|
||||||
|
|
||||||
|
### Current Foundry Support
|
||||||
|
|
||||||
|
**Type**: LLM **Provider Only** (NOT Channel)
|
||||||
|
|
||||||
|
```go
|
||||||
|
case "azure-ai", "azure-foundry":
|
||||||
|
// Azure AI Foundry / Studio compatible with OpenAI API format
|
||||||
|
// Used for LLM backend, not message channeling
|
||||||
|
```
|
||||||
|
|
||||||
|
**What's Missing for Teams/Foundry Integration**:
|
||||||
|
- ❌ No Teams Channel handler
|
||||||
|
- ❌ No Foundry Agent channel integration
|
||||||
|
- ❌ No Teams webhook receiver
|
||||||
|
- ❌ No Teams message routing
|
||||||
|
|
||||||
|
**What Exists**:
|
||||||
|
- ✅ Azure AI Foundry as LLM provider backend
|
||||||
|
- ✅ OpenAI-compatible API handling
|
||||||
|
- ✅ Generic inbound message bus infrastructure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata Field Usage
|
||||||
|
|
||||||
|
All channels populate `InboundMessage.Metadata` with platform-specific data:
|
||||||
|
|
||||||
|
### Example: WeCom (for comparison)
|
||||||
|
**Location**: [pkg/channels/wecom/app.go:605-620](pkg/channels/wecom/app.go#L605-L620)
|
||||||
|
|
||||||
|
```go
|
||||||
|
metadata := map[string]string{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
||||||
|
"agent_id": fmt.Sprintf("%d", msg.AgentID),
|
||||||
|
"platform": "wecom",
|
||||||
|
"media_id": msg.MediaId,
|
||||||
|
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Teams Implementation, Would Include:
|
||||||
|
|
||||||
|
```go
|
||||||
|
metadata := map[string]string{
|
||||||
|
"team_id": msg.TeamsTeamID,
|
||||||
|
"channel_id": msg.TeamsChannelID,
|
||||||
|
"service_url": msg.ServiceURL,
|
||||||
|
"activity_id": msg.ActivityID,
|
||||||
|
"conversation_id": msg.ConversationID,
|
||||||
|
"from_user_id": msg.FromUserID,
|
||||||
|
"platform": "teams",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity Matching System
|
||||||
|
|
||||||
|
**Location**: [pkg/identity/identity.go](pkg/identity/identity.go)
|
||||||
|
|
||||||
|
The framework provides legacy-compatible and modern identity matching:
|
||||||
|
|
||||||
|
### Allowed Formats in Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
allow_from:
|
||||||
|
- "29:abc123" # Raw Teams user ID
|
||||||
|
- "teams:29:abc123" # Canonical format
|
||||||
|
- "@alice" # Username format
|
||||||
|
- "29:abc123|alice" # Compound format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Matching Logic
|
||||||
|
|
||||||
|
```go
|
||||||
|
func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
||||||
|
// 1. Try canonical "platform:id" first
|
||||||
|
if platform, id, ok := ParseCanonicalID(allowed); ok {
|
||||||
|
if sender.CanonicalID == BuildCanonicalID(platform, id) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to PlatformID or Username
|
||||||
|
if sender.PlatformID == allowed { return true }
|
||||||
|
if sender.Username == "@" + allowed { return true }
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a Teams Channel Implementation Would Need
|
||||||
|
|
||||||
|
### Minimum Required Fields in InboundMessage
|
||||||
|
|
||||||
|
```go
|
||||||
|
InboundMessage{
|
||||||
|
Channel: "teams",
|
||||||
|
SenderID: userID, // Teams: "29:uuid"
|
||||||
|
Sender: bus.SenderInfo{
|
||||||
|
Platform: "teams",
|
||||||
|
PlatformID: userID, // "29:uuid"
|
||||||
|
CanonicalID: "teams:29:uuid",
|
||||||
|
Username: userName,
|
||||||
|
DisplayName: displayName,
|
||||||
|
},
|
||||||
|
ChatID: conversationID, // Teams ConversationReference.conversation_id
|
||||||
|
Content: messageContent,
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct" || "channel",
|
||||||
|
ID: channelID || userID,
|
||||||
|
},
|
||||||
|
MessageID: activityID, // Teams Activity ID
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"team_id": teamID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"service_url": serviceURL,
|
||||||
|
// ... other Teams-specific fields
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing Setup in Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agents:
|
||||||
|
routing:
|
||||||
|
- agent_id: "teams-agent"
|
||||||
|
match:
|
||||||
|
channel: "teams"
|
||||||
|
team_id: "team-xyz" # Route by Teams Team ID
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Takeaways for Teams + Foundry Integration
|
||||||
|
|
||||||
|
1. **Framework is Ready**: GenericBus message structure can handle Teams IDs
|
||||||
|
2. **ChatID is Primary**: Use Teams `ConversationReference.conversation_id` as ChatID for isolation
|
||||||
|
3. **SessionKey Auto-Generated**: Routing + DMScope automatically creates session keys
|
||||||
|
4. **Identity System Ready**: Canonical "teams:29:uuid" format supported
|
||||||
|
5. **No Channel Implementation Yet**: Need to implement webhook receiver + message publisher
|
||||||
|
6. **Foundry is Provider Only**: Currently only LLM backend, not messaging channel
|
||||||
|
7. **User ID Format**: Teams uses `29:uuid` format - should populate both PlatformID and CanonicalID
|
||||||
|
8. **Conversation Scope**: Teams conversation_id maps directly to InboundMessage.ChatID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Architecture Files
|
||||||
|
|
||||||
|
| Component | File | Key Types |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| Bus Types | [pkg/bus/types.go](pkg/bus/types.go) | InboundMessage, SenderInfo, Peer |
|
||||||
|
| Routing | [pkg/routing/route.go](pkg/routing/route.go) | RouteInput, ResolvedRoute |
|
||||||
|
| Session Keys | [pkg/routing/session_key.go](pkg/routing/session_key.go) | SessionKeyParams, DM scopes |
|
||||||
|
| Identity | [pkg/identity/identity.go](pkg/identity/identity.go) | BuildCanonicalID, MatchAllowed |
|
||||||
|
| Agent Loop | [pkg/agent/loop.go](pkg/agent/loop.go) | Message processing, session isolation |
|
||||||
|
| Example Channel | [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) | Channel implementation pattern |
|
||||||
315
TEAMS_QUICK_REFERENCE.md
Normal file
315
TEAMS_QUICK_REFERENCE.md
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
# Quick Reference: Teams Integration Questions
|
||||||
|
|
||||||
|
## Q1: Teams Channel Integration - Message Receiving & Processing
|
||||||
|
|
||||||
|
**Status**: ❌ NOT IMPLEMENTED
|
||||||
|
|
||||||
|
**Where it would go**: `pkg/channels/teams/` (currently doesn't exist)
|
||||||
|
|
||||||
|
**Current Similar Implementation**: See [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) for webhook pattern
|
||||||
|
|
||||||
|
**Expected Pattern**:
|
||||||
|
1. HTTP webhook receiver on configured port
|
||||||
|
2. Verify Teams Bot Framework signature
|
||||||
|
3. Parse activity/message payload
|
||||||
|
4. Build `InboundMessage` struct
|
||||||
|
5. Publish to bus via `channel.HandleMessage()` or `messageBus.PublishInbound()`
|
||||||
|
|
||||||
|
**Key Files to Reference**:
|
||||||
|
- [pkg/channels/base.go](pkg/channels/base.go) - Base channel interface
|
||||||
|
- [pkg/channels/manager.go](pkg/channels/manager.go) - Channel registration/lifecycle
|
||||||
|
- [pkg/channels/wecom/app.go:605-650](pkg/channels/wecom/app.go#L605-L650) - HandleMessage pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q2: InboundMessage Structure - All Available Fields
|
||||||
|
|
||||||
|
**Location**: [pkg/bus/types.go:18-35](pkg/bus/types.go#L18-L35)
|
||||||
|
|
||||||
|
### Complete Field List
|
||||||
|
|
||||||
|
| Field | Type | Purpose | Example |
|
||||||
|
|-------|------|---------|---------|
|
||||||
|
| `Channel` | string | Platform identifier | `"teams"` |
|
||||||
|
| `SenderID` | string | Raw user ID | `"29:1ABC123"` |
|
||||||
|
| `Sender` | SenderInfo | Structured identity | (see below) |
|
||||||
|
| `Sender.Platform` | string | Platform name | `"teams"` |
|
||||||
|
| `Sender.PlatformID` | string | User platform ID | `"29:1ABC123"` |
|
||||||
|
| `Sender.CanonicalID` | string | **Normalized format** | `"teams:29:1abc123"` |
|
||||||
|
| `Sender.Username` | string | Handle/username | `"alice"` |
|
||||||
|
| `Sender.DisplayName` | string | Full display name | `"Alice Smith"` |
|
||||||
|
| `ChatID` | string | **Conversation ID (PRIMARY)** | `"teams-conv-abc123"` |
|
||||||
|
| `Content` | string | Message text | `"Hello world"` |
|
||||||
|
| `Media` | []string | Media references | `["media://ref123"]` |
|
||||||
|
| `Peer.Kind` | string | Peer type | `"direct"` \| `"channel"` |
|
||||||
|
| `Peer.ID` | string | Peer ID | User/channel ID |
|
||||||
|
| `MessageID` | string | Platform message ID | `"activity-123"` |
|
||||||
|
| `MediaScope` | string | Media cleanup scope | `"teams:conv-abc123:msg-123"` |
|
||||||
|
| `SessionKey` | string | **Session identifier** | `"agent:bot:teams:direct:29:abc123"` |
|
||||||
|
| `Metadata` | map | Platform-specific data | (see below) |
|
||||||
|
|
||||||
|
### Metadata Map (Platform-Specific)
|
||||||
|
|
||||||
|
```go
|
||||||
|
metadata := map[string]string{
|
||||||
|
"team_id": "T12345",
|
||||||
|
"channel_id": "C12345",
|
||||||
|
"conversation_id": "19:...",
|
||||||
|
"service_url": "https://smba.trafficmanager.net/...",
|
||||||
|
"activity_id": "...",
|
||||||
|
"from_user_id": "29:...",
|
||||||
|
"from_user_name": "alice",
|
||||||
|
"recipient_id": "28:...",
|
||||||
|
"conversation_type": "personal|groupChat|channel",
|
||||||
|
"platform": "teams",
|
||||||
|
// ... any other Teams-specific fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q3: Unique User/Conversation ID Capture from Teams
|
||||||
|
|
||||||
|
### What Teams Provides vs. What PicoClaw Needs
|
||||||
|
|
||||||
|
**Teams → PicoClaw Mapping**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Teams Activity Object
|
||||||
|
├── from.id → SenderID (raw), Sender.PlatformID
|
||||||
|
├── from.aadObjectId → (optional, use if available)
|
||||||
|
├── conversation.id → ChatID (THE KEY FIELD)
|
||||||
|
├── conversation.tenantId → Metadata["tenant_id"]
|
||||||
|
├── channelData.teamsChannelId → Peer.ID (if channel)
|
||||||
|
├── channelData.teamsTeamId → Metadata["team_id"], routing input
|
||||||
|
├── serviceUrl → Metadata["service_url"]
|
||||||
|
└── id → MessageID
|
||||||
|
```
|
||||||
|
|
||||||
|
### ID Construction
|
||||||
|
|
||||||
|
**User Identity Chain**:
|
||||||
|
```
|
||||||
|
Teams: from.id = "29:U123ABC"
|
||||||
|
↓
|
||||||
|
Stored as: SenderID = "29:U123ABC"
|
||||||
|
Stored as: Sender.PlatformID = "29:U123ABC"
|
||||||
|
Normalized as: Sender.CanonicalID = "teams:29:u123abc" (lowercased)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conversation Identity Chain**:
|
||||||
|
```
|
||||||
|
Teams: conversation.id = "19:abc123@thread.v2"
|
||||||
|
↓
|
||||||
|
Stored as: ChatID = "19:abc123@thread.v2" (conversation scope)
|
||||||
|
Used for: Session isolation, message routing, state persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
**Team Identity Chain**:
|
||||||
|
```
|
||||||
|
Teams: channelData.teamsTeamId = "T12345678"
|
||||||
|
↓
|
||||||
|
Stored as: Metadata["team_id"] = "T12345678"
|
||||||
|
↓
|
||||||
|
Used in: Routing cascade (Level 4), agent selection
|
||||||
|
```
|
||||||
|
|
||||||
|
### Canonical ID Format
|
||||||
|
|
||||||
|
Built by [pkg/identity/identity.go:BuildCanonicalID()](pkg/identity/identity.go#L11-L20):
|
||||||
|
|
||||||
|
```go
|
||||||
|
BuildCanonicalID("teams", "29:U123ABC")
|
||||||
|
// Returns: "teams:29:u123abc" (normalized to lowercase)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Used for**:
|
||||||
|
- Access control matching
|
||||||
|
- Cross-platform user linking (via identity_links in config)
|
||||||
|
- User identity validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q4: Foundry Agent Integration Points & ID Provision
|
||||||
|
|
||||||
|
**Status**: ⚠️ PARTIAL - Foundry is an LLM Provider, NOT a Channel
|
||||||
|
|
||||||
|
### Current Foundry Support
|
||||||
|
|
||||||
|
**Location**: [pkg/providers/factory_provider.go:196](pkg/providers/factory_provider.go#L196)
|
||||||
|
|
||||||
|
Foundry is integrated **only as LLM backend** (OpenAI-compatible API):
|
||||||
|
|
||||||
|
```go
|
||||||
|
case "azure-ai", "azure_foundry":
|
||||||
|
// Use for model calls, not messaging
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Foundry Would Provide (if implemented as channel)**:
|
||||||
|
- Foundry Agent service/conversation IDs
|
||||||
|
- Foundry user session tracking
|
||||||
|
- Foundry-specific message format
|
||||||
|
|
||||||
|
**What's MISSING**:
|
||||||
|
1. ❌ Foundry Agent channel receiver
|
||||||
|
2. ❌ Foundry conversation → ChatID mapping
|
||||||
|
3. ❌ Foundry agent ID → Agent routing
|
||||||
|
|
||||||
|
### If Foundry Channel Were to Exist
|
||||||
|
|
||||||
|
Expected `InboundMessage` would be:
|
||||||
|
|
||||||
|
```go
|
||||||
|
InboundMessage{
|
||||||
|
Channel: "foundry-agent",
|
||||||
|
SenderID: foundryUserID,
|
||||||
|
Sender: SenderInfo{
|
||||||
|
Platform: "foundry",
|
||||||
|
PlatformID: foundryUserID,
|
||||||
|
CanonicalID: "foundry:" + foundryUserID,
|
||||||
|
DisplayName: userName,
|
||||||
|
},
|
||||||
|
ChatID: foundryConversationID, // Critical for isolation
|
||||||
|
Content: message,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"foundry_agent_id": agentID,
|
||||||
|
"foundry_conversation_id": conversationID,
|
||||||
|
"foundry_message_id": messageID,
|
||||||
|
"platform": "foundry",
|
||||||
|
// ... other Foundry fields
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Foundry ID Mapping Table (Hypothetical)
|
||||||
|
|
||||||
|
| Foundry ID | InboundMessage Field | Purpose |
|
||||||
|
|----------|----------------------|---------|
|
||||||
|
| Agent ID | Routing/Config | Which agent handles |
|
||||||
|
| User ID | SenderID | Who sent message |
|
||||||
|
| Conversation ID | **ChatID** | Session isolation |
|
||||||
|
| Message ID | MessageID | For threading |
|
||||||
|
| Service Endpoint | Metadata | For API calls |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q5: How ChatID is Currently Used for Session ID Association
|
||||||
|
|
||||||
|
**Location**: [pkg/agent/loop.go:1248-1270](pkg/agent/loop.go#L1248-L1270)
|
||||||
|
|
||||||
|
### ChatID → SessionKey Conversion
|
||||||
|
|
||||||
|
**Process**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. InboundMessage arrives with ChatID
|
||||||
|
↓
|
||||||
|
2. Router resolves agent (via RouteInput)
|
||||||
|
↓
|
||||||
|
3. SessionKey built from:
|
||||||
|
- Agent ID
|
||||||
|
- Channel name
|
||||||
|
- Peer information (ChatID wrapped as Peer.ID)
|
||||||
|
- DMScope configuration
|
||||||
|
↓
|
||||||
|
4. Result: SessionKey = "agent:botname:team:type:id"
|
||||||
|
↓
|
||||||
|
5. SessionKey used to find/create workspace & history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Key Patterns by DMScope
|
||||||
|
|
||||||
|
**From config `session.dm_scope`**:
|
||||||
|
|
||||||
|
| Setting | Session Behavior | Key Format |
|
||||||
|
|---------|------------------|-----------|
|
||||||
|
| Not set / `main` | Single shared session | `agent:bot:main` |
|
||||||
|
| `per_peer` | One session per user | `agent:bot:direct:user123` |
|
||||||
|
| `per_channel_peer` | One per channel+user | `agent:bot:teams:direct:user123` |
|
||||||
|
| `per_account_channel_peer` | One per account+channel+user | `agent:bot:teams:act1:direct:user123` |
|
||||||
|
|
||||||
|
**Code Reference**: [pkg/routing/session_key.go:40-100](pkg/routing/session_key.go#L40-L100)
|
||||||
|
|
||||||
|
### Session Isolation via ChatID
|
||||||
|
|
||||||
|
When ChatID is unique and non-"direct":
|
||||||
|
|
||||||
|
```go
|
||||||
|
// From pkg/agent/loop.go:1248-1270
|
||||||
|
if isolationID != "" && isolationID != "direct" {
|
||||||
|
// Creates isolated agent instance with separate:
|
||||||
|
// - Workspace directory
|
||||||
|
// - Session history
|
||||||
|
// - Memory storage
|
||||||
|
// - State
|
||||||
|
agent = NewAgentInstance(ac, cfg, baseAgent.Provider, isolationID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Isolation Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
ChatID = "teams-channel-abc123"
|
||||||
|
↓
|
||||||
|
Creates: workspace/teams-channel-abc123/
|
||||||
|
├── sessions/
|
||||||
|
├── memory/
|
||||||
|
├── skills/
|
||||||
|
└── state/
|
||||||
|
↓
|
||||||
|
Each channel conversation has completely isolated history
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Persistence
|
||||||
|
|
||||||
|
Tracks last ChatID:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Record last chat for workspace continuity
|
||||||
|
al.RecordLastChatID(chatID) // pkg/agent/loop.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Stored in: `workspace/state/state.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"last_channel": "teams",
|
||||||
|
"last_chat_id": "19:abc123@thread.v2",
|
||||||
|
"timestamp": "2025-03-26T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table: ID Field Mapping
|
||||||
|
|
||||||
|
| Concept | Field | Example | Used For |
|
||||||
|
|---------|-------|---------|----------|
|
||||||
|
| **User** | `SenderID` + `Sender.PlatformID` | `"29:U123ABC"` | Message author |
|
||||||
|
| **User (Normalized)** | `Sender.CanonicalID` | `"teams:29:u123abc"` | Access control |
|
||||||
|
| **Conversation** | `ChatID` | `"19:abc123@thread.v2"` | **Session isolation** |
|
||||||
|
| **Team** | `Metadata["team_id"]` | `"T12345678"` | Agent routing level |
|
||||||
|
| **Channel** | `Peer.Kind` + `Peer.ID` | `"channel:C12345"` | Routing peer |
|
||||||
|
| **Message** | `MessageID` | `"activity-123"` | Threading, dedup |
|
||||||
|
| **Workspace** | Derived from ChatID | `workspace/19:abc123@thread.v2/` | Data isolation |
|
||||||
|
| **Session** | `SessionKey` | `"agent:bot:teams:direct:29:u123abc"` | History tracking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Cross-References
|
||||||
|
|
||||||
|
### For Teams Implementation
|
||||||
|
- Start: [pkg/channels/manager.go](pkg/channels/manager.go) - Channel registration
|
||||||
|
- Reference: [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) - Full implementation pattern
|
||||||
|
- Base: [pkg/channels/base.go](pkg/channels/base.go) - Handler interface
|
||||||
|
|
||||||
|
### For Routing/Session
|
||||||
|
- Routing: [pkg/routing/route.go](pkg/routing/route.go) - 7-level cascade
|
||||||
|
- Keys: [pkg/routing/session_key.go](pkg/routing/session_key.go) - Key building
|
||||||
|
- Isolation: [pkg/agent/loop.go:1248+](pkg/agent/loop.go#L1248) - ChatID isolation
|
||||||
|
|
||||||
|
### For Identity
|
||||||
|
- Identity: [pkg/identity/identity.go](pkg/identity/identity.go) - CanonicalID logic
|
||||||
|
- Matching: Lines 28-100 - Access control matching
|
||||||
|
|
||||||
|
### For State
|
||||||
|
- State: [pkg/state/state.go](pkg/state/state.go) - LastChatID persistence
|
||||||
|
|
@ -12,6 +12,7 @@ var embeddedFiles embed.FS
|
||||||
|
|
||||||
func NewOnboardCommand() *cobra.Command {
|
func NewOnboardCommand() *cobra.Command {
|
||||||
var encrypt bool
|
var encrypt bool
|
||||||
|
var yes bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "onboard",
|
Use: "onboard",
|
||||||
|
|
@ -20,15 +21,19 @@ func NewOnboardCommand() *cobra.Command {
|
||||||
// Run without subcommands → original onboard flow
|
// Run without subcommands → original onboard flow
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
onboard(encrypt)
|
onboard(encrypt, yes)
|
||||||
} else {
|
} else {
|
||||||
_ = cmd.Help()
|
_ = cmd.Help()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(NewPurgeCommand())
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||||
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||||
|
cmd.Flags().BoolVarP(&yes, "yes", "y", false,
|
||||||
|
"Assume 'yes' for all prompts (useful for scripts/Docker non-TTY builds)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/credential"
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
func onboard(encrypt bool) {
|
func onboard(encrypt bool, yes bool) {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
configExists := false
|
configExists := false
|
||||||
|
|
@ -26,12 +26,14 @@ func onboard(encrypt bool) {
|
||||||
if _, err := os.Stat(sshKeyPath); err == nil {
|
if _, err := os.Stat(sshKeyPath); err == nil {
|
||||||
// Both exist — confirm a full reset.
|
// Both exist — confirm a full reset.
|
||||||
fmt.Printf("Config already exists at %s\n", configPath)
|
fmt.Printf("Config already exists at %s\n", configPath)
|
||||||
fmt.Print("Overwrite config with defaults? (y/n): ")
|
if !yes {
|
||||||
var response string
|
fmt.Print("Overwrite config with defaults? (y/n): ")
|
||||||
fmt.Scanln(&response)
|
var response string
|
||||||
if response != "y" {
|
fmt.Scanln(&response)
|
||||||
fmt.Println("Aborted.")
|
if response != "y" {
|
||||||
return
|
fmt.Println("Aborted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
configExists = false // user agreed to reset; treat as fresh
|
configExists = false // user agreed to reset; treat as fresh
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +56,7 @@ func onboard(encrypt bool) {
|
||||||
// the current process and disappears when it exits.
|
// the current process and disappears when it exits.
|
||||||
os.Setenv(credential.PassphraseEnvVar, passphrase)
|
os.Setenv(credential.PassphraseEnvVar, passphrase)
|
||||||
|
|
||||||
if err = setupSSHKey(); err != nil {
|
if err = setupSSHKey(yes); err != nil {
|
||||||
fmt.Printf("Error generating SSH key: %v\n", err)
|
fmt.Printf("Error generating SSH key: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +132,7 @@ func promptPassphrase() (string, error) {
|
||||||
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
|
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
|
||||||
// If the key already exists the user is warned and asked to confirm overwrite.
|
// If the key already exists the user is warned and asked to confirm overwrite.
|
||||||
// Answering anything other than "y" keeps the existing key (not an error).
|
// Answering anything other than "y" keeps the existing key (not an error).
|
||||||
func setupSSHKey() error {
|
func setupSSHKey(yes bool) error {
|
||||||
keyPath, err := credential.DefaultSSHKeyPath()
|
keyPath, err := credential.DefaultSSHKeyPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot determine SSH key path: %w", err)
|
return fmt.Errorf("cannot determine SSH key path: %w", err)
|
||||||
|
|
@ -139,12 +141,14 @@ func setupSSHKey() error {
|
||||||
if _, err := os.Stat(keyPath); err == nil {
|
if _, err := os.Stat(keyPath); err == nil {
|
||||||
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
|
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
|
||||||
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
|
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
|
||||||
fmt.Print(" Overwrite? (y/n): ")
|
if !yes {
|
||||||
var response string
|
fmt.Print(" Overwrite? (y/n): ")
|
||||||
fmt.Scanln(&response)
|
var response string
|
||||||
if response != "y" {
|
fmt.Scanln(&response)
|
||||||
fmt.Println("Keeping existing SSH key.")
|
if response != "y" {
|
||||||
return nil
|
fmt.Println("Keeping existing SSH key.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
58
cmd/picoclaw/internal/onboard/purge.go
Normal file
58
cmd/picoclaw/internal/onboard/purge.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewPurgeCommand() *cobra.Command {
|
||||||
|
var force bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "purge",
|
||||||
|
Short: "Delete the picoclaw workspace and logs",
|
||||||
|
Long: "Completely deletes the .picoclaw/workspace and .picoclaw/logs directories. Use with caution.",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
home := internal.GetPicoclawHome()
|
||||||
|
workspace := filepath.Join(home, "workspace")
|
||||||
|
logs := filepath.Join(home, "logs")
|
||||||
|
|
||||||
|
fmt.Printf("This will delete:\n - %s\n - %s\n", workspace, logs)
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
fmt.Print("Are you sure? (y/n): ")
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if response != "y" {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Purging...")
|
||||||
|
|
||||||
|
if err := os.RemoveAll(workspace); err != nil {
|
||||||
|
fmt.Printf("Error deleting workspace: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Workspace deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.RemoveAll(logs); err != nil {
|
||||||
|
fmt.Printf("Error deleting logs: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Logs deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Purge complete.")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
@ -107,10 +107,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("🔍 Creating startup provider for model: %s (allow empty: %v)\n", cfg.Agents.Defaults.GetModelName(), allowEmptyStartup)
|
||||||
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("❌ Error creating provider: %v\n", err)
|
||||||
return fmt.Errorf("error creating provider: %w", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("✓ Provider created (Model ID: %s)\n", modelID)
|
||||||
|
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.ModelName = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
|
|
@ -133,8 +136,10 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsInfo["available"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fmt.Println("🚀 Setting up services...")
|
||||||
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)
|
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("❌ Error starting services: %v\n", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue