Skip to content

Commit b8cf6ad

Browse files
author
JiaDe
committed
feat: IM user mapping + base_id fix + executive SOUL template
IM User Mapping (SSM-backed): - Backend: CRUD endpoints for /bindings/user-mappings (SSM read/write) - Creating a mapping also writes SSM position for the tenant_id - Frontend: new 'IM User Mappings' tab in Bindings page with add/delete UI, employee selector, channel picker Agent Container fixes: - server.py: fixed base_id extraction for 2-part tenant_ids (unknown__1484960930608578580 now correctly extracts 1484960930608578580 instead of 'unknown') - server.py: SSM user-mapping lookup tries multiple key formats (channel__userId, userId, full tenant_id) - server.py: usage tracking reads resolved base_id from /tmp/base_tenant_id New SOUL template: - pos-exec (Senior Executive): full tool access except finance/HR data restrictions. For demo of role-based data scoping.
1 parent 8298bf4 commit b8cf6ad

7 files changed

Lines changed: 402 additions & 4 deletions

File tree

enterprise/admin-console/server/main.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,9 +609,118 @@ def get_bindings(authorization: str = Header(default="")):
609609
def create_binding(body: dict):
610610
body.setdefault("status", "active")
611611
body.setdefault("createdAt", datetime.now(timezone.utc).isoformat())
612+
# If channel user ID provided, write SSM mapping
613+
channel_user_id = body.get("channelUserId", "")
614+
channel = body.get("channel", "")
615+
employee_id = body.get("employeeId", "")
616+
if channel_user_id and channel and employee_id:
617+
_write_user_mapping(channel, channel_user_id, employee_id)
612618
return db.create_binding(body)
613619

614620

621+
# =========================================================================
622+
# IM User → Employee Mapping (SSM-backed)
623+
# =========================================================================
624+
625+
import boto3 as _boto3_main
626+
627+
def _ssm_client():
628+
return _boto3_main.client("ssm", region_name=os.environ.get("SSM_REGION", os.environ.get("AWS_REGION", "us-east-1")))
629+
630+
def _mapping_prefix():
631+
stack = os.environ.get("STACK_NAME", "openclaw-multitenancy")
632+
return f"/openclaw/{stack}/user-mapping/"
633+
634+
def _write_user_mapping(channel: str, channel_user_id: str, employee_id: str):
635+
"""Write SSM mapping: channel__user_id → employee_id"""
636+
key = f"{channel}__{channel_user_id}"
637+
path = f"{_mapping_prefix()}{key}"
638+
try:
639+
_ssm_client().put_parameter(Name=path, Value=employee_id, Type="String", Overwrite=True)
640+
except Exception as e:
641+
print(f"[user-mapping] SSM write failed: {e}")
642+
643+
def _read_user_mapping(channel: str, channel_user_id: str) -> str:
644+
"""Read SSM mapping: channel__user_id → employee_id"""
645+
key = f"{channel}__{channel_user_id}"
646+
path = f"{_mapping_prefix()}{key}"
647+
try:
648+
resp = _ssm_client().get_parameter(Name=path)
649+
return resp["Parameter"]["Value"]
650+
except Exception:
651+
return ""
652+
653+
def _list_user_mappings() -> list:
654+
"""List all user mappings from SSM."""
655+
prefix = _mapping_prefix()
656+
try:
657+
ssm = _ssm_client()
658+
mappings = []
659+
params = {"Path": prefix, "Recursive": True, "MaxResults": 50}
660+
while True:
661+
resp = ssm.get_parameters_by_path(**params)
662+
for p in resp.get("Parameters", []):
663+
name = p["Name"].replace(prefix, "")
664+
parts = name.split("__", 1)
665+
if len(parts) == 2:
666+
mappings.append({
667+
"channel": parts[0],
668+
"channelUserId": parts[1],
669+
"employeeId": p["Value"],
670+
"ssmPath": p["Name"],
671+
})
672+
token = resp.get("NextToken")
673+
if not token:
674+
break
675+
params["NextToken"] = token
676+
return mappings
677+
except Exception as e:
678+
print(f"[user-mapping] SSM list failed: {e}")
679+
return []
680+
681+
@app.get("/api/v1/bindings/user-mappings")
682+
def get_user_mappings():
683+
"""List all IM user → employee mappings from SSM."""
684+
return _list_user_mappings()
685+
686+
class UserMappingRequest(BaseModel):
687+
channel: str # discord, telegram, slack, whatsapp
688+
channelUserId: str # platform-specific user ID
689+
employeeId: str # emp-carol, emp-w5, etc.
690+
691+
@app.post("/api/v1/bindings/user-mappings")
692+
def create_user_mapping(body: UserMappingRequest):
693+
"""Create or update an IM user → employee mapping in SSM."""
694+
_write_user_mapping(body.channel, body.channelUserId, body.employeeId)
695+
# Also write position mapping for the tenant_id that H2 Proxy derives
696+
emp = next((e for e in db.get_employees() if e["id"] == body.employeeId), None)
697+
if emp:
698+
pos_id = emp.get("positionId", "")
699+
if pos_id:
700+
# Write position for various tenant_id formats the proxy might derive
701+
stack = os.environ.get("STACK_NAME", "openclaw-multitenancy")
702+
ssm = _ssm_client()
703+
for tenant_key in [body.employeeId, f"{body.channel}__{body.channelUserId}"]:
704+
try:
705+
ssm.put_parameter(
706+
Name=f"/openclaw/{stack}/tenants/{tenant_key}/position",
707+
Value=pos_id, Type="String", Overwrite=True)
708+
except Exception:
709+
pass
710+
return {"saved": True, "channel": body.channel, "channelUserId": body.channelUserId, "employeeId": body.employeeId}
711+
712+
@app.delete("/api/v1/bindings/user-mappings")
713+
def delete_user_mapping(channel: str, channelUserId: str):
714+
"""Delete an IM user → employee mapping from SSM."""
715+
key = f"{channel}__{channelUserId}"
716+
path = f"{_mapping_prefix()}{key}"
717+
try:
718+
_ssm_client().delete_parameter(Name=path)
719+
return {"deleted": True}
720+
except Exception as e:
721+
raise HTTPException(500, str(e))
722+
723+
615724
# =========================================================================
616725
# Routing Rules — determines how messages are routed to agents
617726
# =========================================================================
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Senior Executive — Digital Employee
2+
3+
You are a Senior Executive at ACME Corp. You are a high-level decision maker with broad access to company operations.
4+
5+
## Your Role
6+
- Strategic planning and business analysis
7+
- Cross-department coordination
8+
- Technology evaluation and architecture review
9+
- Team management and project oversight
10+
- Client engagement and partnership development
11+
12+
## Personality
13+
- Direct, decisive, and results-oriented
14+
- You think strategically but can dive into technical details when needed
15+
- You communicate clearly and concisely
16+
- You respect data and evidence-based decisions
17+
18+
## Tool Permissions
19+
You have access to: web_search, jina-reader, deep-research, shell, browser, file, file_write, code_execution, github-pr, excel-gen, crm-query, email-send, calendar-check, s3-files, notion-sync
20+
21+
## Data Access Restrictions
22+
**CRITICAL: You MUST NOT access or discuss:**
23+
- Financial reports, budgets, revenue data, or any content from /finance/** paths
24+
- HR records, salary information, employee personal data, or any content from /hr/** paths
25+
- If asked about financial or HR data, explain that your role does not have access to these sensitive areas and suggest contacting the Finance or HR department directly.
26+
27+
## Knowledge Scope
28+
You have access to: company-policies/, arch-standards/, runbooks/, case-studies/, customer-playbooks/
29+
You do NOT have access to: financial-reports/, hr-policies/, contract-templates/
30+
31+
## Core Competencies
32+
- AWS architecture and cloud strategy
33+
- Agile/Scrum project management
34+
- Business case development and ROI analysis
35+
- Technical due diligence
36+
- Cross-functional team leadership

enterprise/admin-console/src/hooks/useApi.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,37 @@ export function useRoutingRules() {
469469
queryFn: () => api.get('/routing/rules'),
470470
});
471471
}
472+
473+
// === IM User Mappings ===
474+
475+
export interface UserMapping {
476+
channel: string;
477+
channelUserId: string;
478+
employeeId: string;
479+
ssmPath?: string;
480+
}
481+
482+
export function useUserMappings() {
483+
return useQuery<UserMapping[]>({
484+
queryKey: ['user-mappings'],
485+
queryFn: () => api.get('/bindings/user-mappings'),
486+
});
487+
}
488+
489+
export function useCreateUserMapping() {
490+
const qc = useQueryClient();
491+
return useMutation({
492+
mutationFn: (data: { channel: string; channelUserId: string; employeeId: string }) =>
493+
api.post<{ saved: boolean }>('/bindings/user-mappings', data),
494+
onSuccess: () => qc.invalidateQueries({ queryKey: ['user-mappings'] }),
495+
});
496+
}
497+
498+
export function useDeleteUserMapping() {
499+
const qc = useQueryClient();
500+
return useMutation({
501+
mutationFn: (data: { channel: string; channelUserId: string }) =>
502+
api.del<{ deleted: boolean }>(`/bindings/user-mappings?channel=${data.channel}&channelUserId=${data.channelUserId}`),
503+
onSuccess: () => qc.invalidateQueries({ queryKey: ['user-mappings'] }),
504+
});
505+
}

enterprise/admin-console/src/pages/Bindings.tsx

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState } from 'react';
2-
import { Link2, Plus, Users, User, GitBranch } from 'lucide-react';
2+
import { Link2, Plus, Users, User, GitBranch, Smartphone, Trash2 } from 'lucide-react';
33
import { Card, StatCard, Badge, Button, PageHeader, Table, Modal, Select, Tabs, StatusDot } from '../components/ui';
4-
import { useBindings, useEmployees, useAgents, usePositions, useCreateBinding, useBulkProvision, useRoutingRules } from '../hooks/useApi';
4+
import { useBindings, useEmployees, useAgents, usePositions, useCreateBinding, useBulkProvision, useRoutingRules, useUserMappings, useCreateUserMapping, useDeleteUserMapping } from '../hooks/useApi';
55
import { CHANNEL_LABELS } from '../types';
66
import type { Binding, ChannelType } from '../types';
77

@@ -13,8 +13,15 @@ export default function Bindings() {
1313
const createBinding = useCreateBinding();
1414
const bulkProvision = useBulkProvision();
1515
const { data: routingRules = [] } = useRoutingRules();
16+
const { data: userMappings = [] } = useUserMappings();
17+
const createUserMapping = useCreateUserMapping();
18+
const deleteUserMapping = useDeleteUserMapping();
1619
const [showCreate, setShowCreate] = useState(false);
1720
const [showBulk, setShowBulk] = useState(false);
21+
const [showMapping, setShowMapping] = useState(false);
22+
const [mapChannel, setMapChannel] = useState('discord');
23+
const [mapUserId, setMapUserId] = useState('');
24+
const [mapEmpId, setMapEmpId] = useState('');
1825
const [bulkPos, setBulkPos] = useState('');
1926
const [bulkChannel, setBulkChannel] = useState('slack');
2027
const [bulkResult, setBulkResult] = useState<any>(null);
@@ -80,6 +87,7 @@ export default function Bindings() {
8087
{ id: 'shared', label: 'N:1 Shared', count: shared.length },
8188
{ id: 'multi', label: '1:N Multi-Agent', count: multi.length },
8289
{ id: 'routing', label: 'Routing Rules', count: routingRules.length },
90+
{ id: 'mappings', label: 'IM User Mappings', count: userMappings.length },
8391
]}
8492
activeTab={activeTab}
8593
onChange={setActiveTab}
@@ -108,6 +116,36 @@ export default function Bindings() {
108116
data={routingRules}
109117
/>
110118
</div>
119+
) : activeTab === 'mappings' ? (
120+
<div>
121+
<div className="flex items-center justify-between mb-4">
122+
<p className="text-sm text-text-secondary">Map IM platform user IDs to employee IDs. This tells the system which employee is behind each Discord/Telegram/Slack/WhatsApp account.</p>
123+
<Button variant="primary" onClick={() => setShowMapping(true)}><Smartphone size={14} className="mr-1" /> Add Mapping</Button>
124+
</div>
125+
{userMappings.length === 0 ? (
126+
<div className="text-center py-8 text-text-muted">
127+
<Smartphone size={32} className="mx-auto mb-2 opacity-50" />
128+
<p className="text-sm">No IM user mappings configured yet.</p>
129+
<p className="text-xs mt-1">Add mappings so the system knows which employee is behind each IM account.</p>
130+
</div>
131+
) : (
132+
<Table
133+
columns={[
134+
{ key: 'channel', label: 'Channel', render: (r: typeof userMappings[0]) => <Badge color="info">{r.channel}</Badge> },
135+
{ key: 'channelUserId', label: 'Platform User ID', render: (r: typeof userMappings[0]) => <code className="text-xs bg-dark-bg px-2 py-0.5 rounded">{r.channelUserId}</code> },
136+
{ key: 'employeeId', label: 'Employee', render: (r: typeof userMappings[0]) => {
137+
const emp = EMPLOYEES.find(e => e.id === r.employeeId);
138+
return <span className="font-medium">{emp?.name || r.employeeId}</span>;
139+
}},
140+
{ key: 'actions', label: '', render: (r: typeof userMappings[0]) => (
141+
<button onClick={() => deleteUserMapping.mutate({ channel: r.channel, channelUserId: r.channelUserId })}
142+
className="text-text-muted hover:text-danger transition-colors"><Trash2 size={14} /></button>
143+
)},
144+
]}
145+
data={userMappings}
146+
/>
147+
)}
148+
</div>
111149
) : (
112150
<Table columns={columns} data={tabData[activeTab] || []} />
113151
)}
@@ -242,6 +280,40 @@ export default function Bindings() {
242280
</div>
243281
)}
244282
</Modal>
283+
284+
{/* IM User Mapping Modal */}
285+
<Modal
286+
open={showMapping} onClose={() => { setShowMapping(false); setMapChannel('discord'); setMapUserId(''); setMapEmpId(''); }}
287+
title="Add IM User Mapping"
288+
footer={<div className="flex justify-end gap-3">
289+
<Button variant="default" onClick={() => setShowMapping(false)}>Cancel</Button>
290+
<Button variant="primary" disabled={!mapUserId || !mapEmpId || createUserMapping.isPending} onClick={() => {
291+
createUserMapping.mutate({ channel: mapChannel, channelUserId: mapUserId, employeeId: mapEmpId }, {
292+
onSuccess: () => { setShowMapping(false); setMapChannel('discord'); setMapUserId(''); setMapEmpId(''); },
293+
});
294+
}}>{createUserMapping.isPending ? 'Saving...' : 'Save Mapping'}</Button>
295+
</div>}
296+
>
297+
<div className="space-y-4">
298+
<p className="text-sm text-text-secondary">Map an IM platform user ID to an employee. This tells the system which employee is behind each IM account, so their agent gets the correct SOUL identity and permissions.</p>
299+
<Select label="IM Channel" value={mapChannel} onChange={setMapChannel} options={[
300+
{ label: 'Discord', value: 'discord' },
301+
{ label: 'Telegram', value: 'telegram' },
302+
{ label: 'Slack', value: 'slack' },
303+
{ label: 'WhatsApp', value: 'whatsapp' },
304+
]} />
305+
<div>
306+
<label className="block text-xs font-medium text-text-secondary mb-1">Platform User ID</label>
307+
<input value={mapUserId} onChange={e => setMapUserId(e.target.value)}
308+
placeholder="e.g. 1460888812426363004 (Discord) or 987654321 (Telegram)"
309+
className="w-full rounded-lg border border-dark-border bg-dark-bg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-primary focus:outline-none" />
310+
<p className="text-xs text-text-muted mt-1">Find this in the IM platform's user profile or from the pairing log.</p>
311+
</div>
312+
<Select label="Employee" value={mapEmpId} onChange={setMapEmpId}
313+
options={EMPLOYEES.map(e => ({ label: `${e.name} (${e.positionName})`, value: e.id }))}
314+
placeholder="Select employee" />
315+
</div>
316+
</Modal>
245317
</div>
246318
);
247319
}

enterprise/agent-container/server.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,44 @@ def _ensure_workspace_assembled(tenant_id: str) -> None:
136136
logger.info("First invocation for tenant %s — assembling workspace", tenant_id)
137137

138138
# Extract base employee ID for S3 paths
139+
# Tenant ID formats:
140+
# port__emp-carol__bbee1f93 → base = emp-carol (Portal, 3 parts)
141+
# tg__emp-w5__a1b2c3d4 → base = emp-w5 (Telegram, 3 parts)
142+
# unknown__1484960930608578580 → base = 1484960930608578580 (Discord via H2 Proxy, 2 parts)
143+
# actions__a → base = a (H2 Proxy fallback, 2 parts)
144+
# emp-carol → base = emp-carol (direct)
139145
base_id = tenant_id
140146
parts = tenant_id.split("__")
141147
if len(parts) >= 3:
148+
# channel__user_id__hash → take user_id (middle)
142149
base_id = parts[1]
143-
elif len(parts) == 2 and len(parts[1]) > 10:
144-
base_id = parts[0]
150+
elif len(parts) == 2:
151+
# channel__user_id → take user_id (second part, the actual identifier)
152+
base_id = parts[1]
153+
154+
# Check SSM user-mapping for IM channel user IDs
155+
# e.g., discord__1460888812426363004 → emp-carol
156+
if not base_id.startswith("emp-"):
157+
try:
158+
import boto3 as _b3_mapping
159+
ssm = _b3_mapping.client("ssm", region_name=AWS_REGION_RUNTIME)
160+
# Try multiple mapping key formats
161+
mapping_keys = [
162+
f"{parts[0]}__{base_id}" if len(parts) >= 2 else base_id, # channel__userId
163+
base_id, # just userId
164+
tenant_id, # full tenant_id
165+
]
166+
for mapping_key in mapping_keys:
167+
try:
168+
resp = ssm.get_parameter(Name=f"/openclaw/{STACK_NAME}/user-mapping/{mapping_key}")
169+
resolved = resp["Parameter"]["Value"]
170+
logger.info("SSM user-mapping resolved: %s → %s", mapping_key, resolved)
171+
base_id = resolved
172+
break
173+
except Exception:
174+
pass
175+
except Exception as e:
176+
logger.warning("SSM user-mapping lookup failed: %s", e)
145177

146178
# 1. Sync tenant's personal workspace from S3 using BASE ID
147179
s3_base = f"s3://{S3_BUCKET}/{base_id}"
@@ -480,6 +512,16 @@ def _handle_invocation(self, tenant_id: str, message: str, payload: dict):
480512
parts = tenant_id.split("__")
481513
if len(parts) >= 3:
482514
base_id = parts[1]
515+
elif len(parts) == 2:
516+
base_id = parts[1]
517+
# Use resolved base_id from workspace assembly if available
518+
try:
519+
with open("/tmp/base_tenant_id") as f:
520+
resolved = f.read().strip()
521+
if resolved and resolved != "unknown":
522+
base_id = resolved
523+
except Exception:
524+
pass
483525
threading.Thread(
484526
target=_write_usage_to_dynamodb,
485527
args=(tenant_id, base_id, usage, model, duration_ms),

enterprise/patch_proxy_debug.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Patch bedrock_proxy_h2.js to add debug logging for message extraction."""
2+
import sys
3+
4+
path = "/home/ubuntu/bedrock_proxy_h2.js"
5+
with open(path) as f:
6+
code = f.read()
7+
8+
old = "log(`Request: ${path}"
9+
new = """log(`DEBUG-SYS: ${JSON.stringify((parsed.system||[]).map(s=>typeof s==='string'?s:s.text||'')).slice(0,500)}`);
10+
log(`DEBUG-MSG: ${userText.slice(0,300)}`);
11+
log(`Request: ${path}"""
12+
13+
if old in code:
14+
code = code.replace(old, new, 1)
15+
with open(path, 'w') as f:
16+
f.write(code)
17+
print("PATCHED OK")
18+
else:
19+
print("Pattern not found — already patched or file changed")

0 commit comments

Comments
 (0)