refactor(modules): extract permissions as optional module
Moves user-roles / users / agent-group-members / user-dms / dropped-messages / user-dm / canAccessAgentGroup into src/modules/permissions/. Module registers a single inbound-gate that owns sender resolution, access decision, unknown-sender policy, and drop-audit recording. Router slimmed from 357 → 179 lines; the inline fallback chain (extractAndUpsertUser / enforceAccess / handleUnknownSender / recordDroppedMessage) is gone — without the permissions module core defaults to allow-all with userId=null. container-runner's admin-ID query is now inline SQL guarded by sqlite_master on user_roles, keeping core free of any import from the permissions module. The container-side formatter falls back to permissionless mode when NANOCLAW_ADMIN_USER_IDS is empty: every sender with an identifiable senderId is treated as admin. Module contract doc formalizes the tier model and the dependency rule (core ← default modules ← optional modules). One transitional violation flagged: src/access.ts (core) imports from the permissions module for its remaining approver-picking helpers; resolves in the planned PR #7 re-tier. Validation: host build clean, 137/137 host tests, 17/17 container tests, typecheck clean, service boots to "NanoClaw running" with permissions module registering its gate and clean SIGTERM shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,4 +16,5 @@
|
||||
import './interactive/index.js';
|
||||
import './approvals/index.js';
|
||||
import './scheduling/index.js';
|
||||
import './permissions/index.js';
|
||||
|
||||
|
||||
29
src/modules/permissions/access.ts
Normal file
29
src/modules/permissions/access.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Access control (permissions module half of src/access.ts).
|
||||
*
|
||||
* Privilege is user-level, not group-level. A user holds zero or more roles
|
||||
* (owner | admin) via `user_roles`, and is optionally "known" in specific
|
||||
* agent groups via `agent_group_members`. Admins are implicitly members of
|
||||
* the groups they administer.
|
||||
*
|
||||
* The approver-picking functions (pickApprover, pickApprovalDelivery) stay
|
||||
* in src/access.ts for now — they move into the approvals module in the
|
||||
* planned PR #7 re-tier.
|
||||
*/
|
||||
import { isMember } from './db/agent-group-members.js';
|
||||
import { isAdminOfAgentGroup, isGlobalAdmin, isOwner } from './db/user-roles.js';
|
||||
import { getUser } from './db/users.js';
|
||||
|
||||
export type AccessDecision =
|
||||
| { allowed: true; reason: 'owner' | 'global_admin' | 'admin_of_group' | 'member' }
|
||||
| { allowed: false; reason: 'unknown_user' | 'not_member' };
|
||||
|
||||
/** Can this user interact with this agent group? */
|
||||
export function canAccessAgentGroup(userId: string, agentGroupId: string): AccessDecision {
|
||||
if (!getUser(userId)) return { allowed: false, reason: 'unknown_user' };
|
||||
if (isOwner(userId)) return { allowed: true, reason: 'owner' };
|
||||
if (isGlobalAdmin(userId)) return { allowed: true, reason: 'global_admin' };
|
||||
if (isAdminOfAgentGroup(userId, agentGroupId)) return { allowed: true, reason: 'admin_of_group' };
|
||||
if (isMember(userId, agentGroupId)) return { allowed: true, reason: 'member' };
|
||||
return { allowed: false, reason: 'not_member' };
|
||||
}
|
||||
44
src/modules/permissions/db/agent-group-members.ts
Normal file
44
src/modules/permissions/db/agent-group-members.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { AgentGroupMember } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
import { isAdminOfAgentGroup, isGlobalAdmin, isOwner } from './user-roles.js';
|
||||
|
||||
export function addMember(row: AgentGroupMember): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES (@user_id, @agent_group_id, @added_by, @added_at)`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
export function removeMember(userId: string, agentGroupId: string): void {
|
||||
getDb().prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?').run(userId, agentGroupId);
|
||||
}
|
||||
|
||||
export function getMembers(agentGroupId: string): AgentGroupMember[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM agent_group_members WHERE agent_group_id = ? ORDER BY added_at')
|
||||
.all(agentGroupId) as AgentGroupMember[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the user "known" in this agent group?
|
||||
* Owner, global admin, and scoped admin are implicitly members.
|
||||
*/
|
||||
export function isMember(userId: string, agentGroupId: string): boolean {
|
||||
if (isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId)) {
|
||||
return true;
|
||||
}
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
|
||||
.get(userId, agentGroupId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Direct row lookup — does not honor the admin/owner implicit-membership rule. */
|
||||
export function hasMembershipRow(userId: string, agentGroupId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
|
||||
.get(userId, agentGroupId);
|
||||
return !!row;
|
||||
}
|
||||
44
src/modules/permissions/db/dropped-messages.ts
Normal file
44
src/modules/permissions/db/dropped-messages.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
export interface UnregisteredSender {
|
||||
channel_type: string;
|
||||
platform_id: string;
|
||||
user_id: string | null;
|
||||
sender_name: string | null;
|
||||
reason: string;
|
||||
messaging_group_id: string | null;
|
||||
agent_group_id: string | null;
|
||||
message_count: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export function recordDroppedMessage(msg: {
|
||||
channel_type: string;
|
||||
platform_id: string;
|
||||
user_id: string | null;
|
||||
sender_name: string | null;
|
||||
reason: string;
|
||||
messaging_group_id: string | null;
|
||||
agent_group_id: string | null;
|
||||
}): void {
|
||||
const now = new Date().toISOString();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO unregistered_senders (channel_type, platform_id, user_id, sender_name, reason, messaging_group_id, agent_group_id, message_count, first_seen, last_seen)
|
||||
VALUES (@channel_type, @platform_id, @user_id, @sender_name, @reason, @messaging_group_id, @agent_group_id, 1, @now, @now)
|
||||
ON CONFLICT (channel_type, platform_id) DO UPDATE SET
|
||||
user_id = COALESCE(excluded.user_id, unregistered_senders.user_id),
|
||||
sender_name = COALESCE(excluded.sender_name, unregistered_senders.sender_name),
|
||||
reason = excluded.reason,
|
||||
message_count = unregistered_senders.message_count + 1,
|
||||
last_seen = excluded.last_seen`,
|
||||
)
|
||||
.run({ ...msg, now });
|
||||
}
|
||||
|
||||
export function getUnregisteredSenders(limit = 50): UnregisteredSender[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM unregistered_senders ORDER BY last_seen DESC LIMIT ?')
|
||||
.all(limit) as UnregisteredSender[];
|
||||
}
|
||||
28
src/modules/permissions/db/user-dms.ts
Normal file
28
src/modules/permissions/db/user-dms.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { UserDm } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
export function upsertUserDm(row: UserDm): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES (@user_id, @channel_type, @messaging_group_id, @resolved_at)
|
||||
ON CONFLICT(user_id, channel_type) DO UPDATE SET
|
||||
messaging_group_id = excluded.messaging_group_id,
|
||||
resolved_at = excluded.resolved_at`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
export function getUserDm(userId: string, channelType: string): UserDm | undefined {
|
||||
return getDb().prepare('SELECT * FROM user_dms WHERE user_id = ? AND channel_type = ?').get(userId, channelType) as
|
||||
| UserDm
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function getUserDmsForUser(userId: string): UserDm[] {
|
||||
return getDb().prepare('SELECT * FROM user_dms WHERE user_id = ?').all(userId) as UserDm[];
|
||||
}
|
||||
|
||||
export function deleteUserDm(userId: string, channelType: string): void {
|
||||
getDb().prepare('DELETE FROM user_dms WHERE user_id = ? AND channel_type = ?').run(userId, channelType);
|
||||
}
|
||||
85
src/modules/permissions/db/user-roles.ts
Normal file
85
src/modules/permissions/db/user-roles.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { UserRole, UserRoleKind } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
/**
|
||||
* Grant a role. Owner rows must have agent_group_id = null (enforced here,
|
||||
* not by schema, so callers get a clean error path).
|
||||
*/
|
||||
export function grantRole(row: UserRole): void {
|
||||
if (row.role === 'owner' && row.agent_group_id !== null) {
|
||||
throw new Error('owner role must be global (agent_group_id = null)');
|
||||
}
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES (@user_id, @role, @agent_group_id, @granted_by, @granted_at)`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
export function revokeRole(userId: string, role: UserRoleKind, agentGroupId: string | null): void {
|
||||
if (agentGroupId === null) {
|
||||
getDb()
|
||||
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL')
|
||||
.run(userId, role);
|
||||
} else {
|
||||
getDb()
|
||||
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ?')
|
||||
.run(userId, role, agentGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserRoles(userId: string): UserRole[] {
|
||||
return getDb().prepare('SELECT * FROM user_roles WHERE user_id = ?').all(userId) as UserRole[];
|
||||
}
|
||||
|
||||
export function isOwner(userId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
|
||||
.get(userId, 'owner');
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function isGlobalAdmin(userId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
|
||||
.get(userId, 'admin');
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function isAdminOfAgentGroup(userId: string, agentGroupId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ? LIMIT 1')
|
||||
.get(userId, 'admin', agentGroupId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Any admin privilege over this agent group: global admin OR scoped admin. */
|
||||
export function hasAdminPrivilege(userId: string, agentGroupId: string): boolean {
|
||||
return isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId);
|
||||
}
|
||||
|
||||
export function getOwners(): UserRole[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
|
||||
.all('owner') as UserRole[];
|
||||
}
|
||||
|
||||
export function hasAnyOwner(): boolean {
|
||||
const row = getDb()
|
||||
.prepare('SELECT 1 FROM user_roles WHERE role = ? AND agent_group_id IS NULL LIMIT 1')
|
||||
.get('owner');
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function getGlobalAdmins(): UserRole[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
|
||||
.all('admin') as UserRole[];
|
||||
}
|
||||
|
||||
export function getAdminsOfAgentGroup(agentGroupId: string): UserRole[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id = ? ORDER BY granted_at')
|
||||
.all('admin', agentGroupId) as UserRole[];
|
||||
}
|
||||
38
src/modules/permissions/db/users.ts
Normal file
38
src/modules/permissions/db/users.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { User } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
export function createUser(user: User): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users (id, kind, display_name, created_at)
|
||||
VALUES (@id, @kind, @display_name, @created_at)`,
|
||||
)
|
||||
.run(user);
|
||||
}
|
||||
|
||||
export function upsertUser(user: User): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users (id, kind, display_name, created_at)
|
||||
VALUES (@id, @kind, @display_name, @created_at)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
display_name = COALESCE(excluded.display_name, users.display_name)`,
|
||||
)
|
||||
.run(user);
|
||||
}
|
||||
|
||||
export function getUser(id: string): User | undefined {
|
||||
return getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
export function getAllUsers(): User[] {
|
||||
return getDb().prepare('SELECT * FROM users ORDER BY created_at').all() as User[];
|
||||
}
|
||||
|
||||
export function updateDisplayName(id: string, displayName: string): void {
|
||||
getDb().prepare('UPDATE users SET display_name = ? WHERE id = ?').run(displayName, id);
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): void {
|
||||
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
}
|
||||
134
src/modules/permissions/index.ts
Normal file
134
src/modules/permissions/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Permissions module — sender resolution + access gate.
|
||||
*
|
||||
* Registers a single inbound-gate via setInboundGate(). The gate owns:
|
||||
* 1. Sender resolution: parse the channel adapter's payload, derive a
|
||||
* namespaced user id, and upsert the `users` row on first sight so
|
||||
* role/access lookups land on a real record thereafter.
|
||||
* 2. Access decision: owners → global admins → scoped admins → members.
|
||||
* 3. Unknown-sender policy: strict drops; request_approval is a TODO
|
||||
* (pending the `add_group_member` action kind).
|
||||
* 4. Audit trail: drops get logged into `dropped_messages`.
|
||||
*
|
||||
* Without this module: core's router defaults to allow-all (PR #2), every
|
||||
* message routes through, and no users table is needed. Drops are not
|
||||
* recorded anywhere. Admin commands inside the container fall back to
|
||||
* permissionless mode (see formatter.ts).
|
||||
*/
|
||||
import { setInboundGate, type InboundEvent, type InboundGateResult } from '../../router.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup } from '../../types.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
import { getUser, upsertUser } from './db/users.js';
|
||||
|
||||
function extractAndUpsertUser(event: InboundEvent): string | null {
|
||||
let content: Record<string, unknown>;
|
||||
try {
|
||||
content = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// chat-sdk-bridge serializes author info as a nested `author.userId` and
|
||||
// does NOT populate top-level `senderId`. Older adapters (v1, native) put
|
||||
// `senderId` or `sender` directly at the top level. Check all three.
|
||||
const senderIdField = typeof content.senderId === 'string' ? content.senderId : undefined;
|
||||
const senderField = typeof content.sender === 'string' ? content.sender : undefined;
|
||||
const author =
|
||||
typeof content.author === 'object' && content.author !== null
|
||||
? (content.author as Record<string, unknown>)
|
||||
: undefined;
|
||||
const authorUserId = typeof author?.userId === 'string' ? (author.userId as string) : undefined;
|
||||
const senderName =
|
||||
(typeof content.senderName === 'string' ? content.senderName : undefined) ??
|
||||
(typeof author?.fullName === 'string' ? (author.fullName as string) : undefined) ??
|
||||
(typeof author?.userName === 'string' ? (author.userName as string) : undefined);
|
||||
|
||||
const rawHandle = senderIdField ?? senderField ?? authorUserId;
|
||||
if (!rawHandle) return null;
|
||||
|
||||
const userId = rawHandle.includes(':') ? rawHandle : `${event.channelType}:${rawHandle}`;
|
||||
if (!getUser(userId)) {
|
||||
upsertUser({
|
||||
id: userId,
|
||||
kind: event.channelType,
|
||||
display_name: senderName ?? null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function safeParseContent(raw: string): { text?: string; sender?: string; senderId?: string } {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { text: raw };
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnknownSender(
|
||||
mg: MessagingGroup,
|
||||
userId: string | null,
|
||||
agentGroupId: string,
|
||||
accessReason: string,
|
||||
event: InboundEvent,
|
||||
): void {
|
||||
const parsed = safeParseContent(event.message.content);
|
||||
const dropRecord = {
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
user_id: userId,
|
||||
sender_name: parsed.sender ?? null,
|
||||
reason: `unknown_sender_${mg.unknown_sender_policy}`,
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: agentGroupId,
|
||||
};
|
||||
|
||||
if (mg.unknown_sender_policy === 'strict') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (strict policy)', {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mg.unknown_sender_policy === 'request_approval') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
return;
|
||||
}
|
||||
|
||||
// 'public' should have been handled before the gate; fall through silently.
|
||||
}
|
||||
|
||||
setInboundGate((event, mg, agentGroupId): InboundGateResult => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
|
||||
// Public channels skip the access check entirely.
|
||||
if (mg.unknown_sender_policy === 'public') {
|
||||
return { allowed: true, userId };
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
handleUnknownSender(mg, null, agentGroupId, 'unknown_user', event);
|
||||
return { allowed: false, userId: null, reason: 'unknown_user' };
|
||||
}
|
||||
|
||||
const decision = canAccessAgentGroup(userId, agentGroupId);
|
||||
if (decision.allowed) {
|
||||
return { allowed: true, userId };
|
||||
}
|
||||
|
||||
handleUnknownSender(mg, userId, agentGroupId, decision.reason, event);
|
||||
return { allowed: false, userId, reason: decision.reason };
|
||||
});
|
||||
146
src/modules/permissions/user-dm.ts
Normal file
146
src/modules/permissions/user-dm.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* User DM resolution.
|
||||
*
|
||||
* Exposes one primitive: `ensureUserDm(userId)` returns (or lazily creates)
|
||||
* the `messaging_groups` row that the host should deliver to when it wants
|
||||
* to DM a given user. Everything that needs to cold-DM a user — approvals,
|
||||
* pairing handshakes, host notifications — goes through this function.
|
||||
*
|
||||
* ## Two-class resolution
|
||||
*
|
||||
* Channels split cleanly into two classes based on whether the user id is
|
||||
* already the DM platform id:
|
||||
*
|
||||
* - **Direct-addressable** (Telegram, WhatsApp, iMessage, email, Matrix):
|
||||
* user handle IS the DM chat id. No adapter method needed; we just
|
||||
* mint a messaging_group row with `platform_id = handle`.
|
||||
*
|
||||
* - **Resolution-required** (Discord, Slack, Teams, Webex, gChat):
|
||||
* user id and DM channel id are different. The adapter must implement
|
||||
* `openDM(handle)`, which Chat SDK's `chat.openDM` handles for us via
|
||||
* the bridge. The returned channel id becomes the `platform_id`.
|
||||
*
|
||||
* ## Caching
|
||||
*
|
||||
* Successful resolutions are persisted in `user_dms (user_id, channel_type
|
||||
* → messaging_group_id)`. The cache survives restarts; first-time DMs on a
|
||||
* given channel pay one `openDM` round trip, everyone after is a pure DB
|
||||
* read.
|
||||
*
|
||||
* The underlying platform APIs (`POST /users/@me/channels` on Discord,
|
||||
* `conversations.open` on Slack, etc.) are idempotent and return the same
|
||||
* channel on repeated calls, so re-resolving after a cache miss is always
|
||||
* safe — worst case we round-trip redundantly.
|
||||
*/
|
||||
import { getChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { getMessagingGroup, getMessagingGroupByPlatform, createMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, User } from '../../types.js';
|
||||
import { getUser } from './db/users.js';
|
||||
import { getUserDm, upsertUserDm } from './db/user-dms.js';
|
||||
|
||||
/**
|
||||
* Return a messaging_group usable to DM this user, creating it lazily if
|
||||
* needed. Returns null when:
|
||||
* - the user id isn't namespaced (no `kind:handle` prefix)
|
||||
* - the user's channel has no adapter registered
|
||||
* - the channel needs openDM but its adapter doesn't implement it
|
||||
* - openDM throws (platform error, user blocked bot, etc.)
|
||||
*
|
||||
* Callers should treat null as "this user is unreachable on this channel".
|
||||
*/
|
||||
export async function ensureUserDm(userId: string): Promise<MessagingGroup | null> {
|
||||
const user = getUser(userId);
|
||||
if (!user) {
|
||||
log.warn('ensureUserDm: user not found', { userId });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { channelType, handle } = parseUserId(user);
|
||||
if (!channelType || !handle) {
|
||||
log.warn('ensureUserDm: user id not namespaced', { userId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache hit: existing user_dms row → load and return the messaging_group.
|
||||
const cached = getUserDm(userId, channelType);
|
||||
if (cached) {
|
||||
const mg = getMessagingGroup(cached.messaging_group_id);
|
||||
if (mg) return mg;
|
||||
// Row points to a deleted messaging_group — fall through and re-resolve.
|
||||
log.warn('ensureUserDm: cached row references missing messaging_group, re-resolving', {
|
||||
userId,
|
||||
messagingGroupId: cached.messaging_group_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Cache miss: resolve the DM platform_id either via openDM or directly.
|
||||
const dmPlatformId = await resolveDmPlatformId(channelType, handle);
|
||||
if (!dmPlatformId) return null;
|
||||
|
||||
// Find-or-create the underlying messaging_group. A DM we received
|
||||
// earlier may already have a row matching (channel_type, platform_id).
|
||||
const now = new Date().toISOString();
|
||||
let mg = getMessagingGroupByPlatform(channelType, dmPlatformId);
|
||||
if (!mg) {
|
||||
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
mg = {
|
||||
id: mgId,
|
||||
channel_type: channelType,
|
||||
platform_id: dmPlatformId,
|
||||
name: user.display_name,
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now,
|
||||
};
|
||||
createMessagingGroup(mg);
|
||||
log.info('ensureUserDm: created DM messaging_group', {
|
||||
userId,
|
||||
channelType,
|
||||
messagingGroupId: mgId,
|
||||
});
|
||||
}
|
||||
|
||||
upsertUserDm({
|
||||
user_id: userId,
|
||||
channel_type: channelType,
|
||||
messaging_group_id: mg.id,
|
||||
resolved_at: now,
|
||||
});
|
||||
|
||||
return mg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the adapter's openDM if it has one; otherwise fall through to using
|
||||
* the handle directly. Returns null if the adapter is missing entirely.
|
||||
*/
|
||||
async function resolveDmPlatformId(channelType: string, handle: string): Promise<string | null> {
|
||||
const adapter = getChannelAdapter(channelType);
|
||||
if (!adapter) {
|
||||
log.warn('ensureUserDm: no adapter for channel', { channelType });
|
||||
return null;
|
||||
}
|
||||
if (!adapter.openDM) {
|
||||
// Direct-addressable channel — handle doubles as the DM chat id.
|
||||
return handle;
|
||||
}
|
||||
try {
|
||||
return await adapter.openDM(handle);
|
||||
} catch (err) {
|
||||
log.error('ensureUserDm: adapter.openDM failed', { channelType, handle, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUserId(user: User): { channelType: string; handle: string } | { channelType: null; handle: null } {
|
||||
const idx = user.id.indexOf(':');
|
||||
if (idx < 0) return { channelType: null, handle: null };
|
||||
const channelType = user.id.slice(0, idx);
|
||||
const handle = user.id.slice(idx + 1);
|
||||
if (!channelType || !handle) return { channelType: null, handle: null };
|
||||
// The `kind` on users mirrors the channel_type prefix in our current
|
||||
// scheme. Pull it from `user.kind` if we ever decouple them later, but
|
||||
// today the id prefix is authoritative.
|
||||
return { channelType, handle };
|
||||
}
|
||||
Reference in New Issue
Block a user