Files
nanoclaw/src/cli/resources/messaging-groups.ts
T
gavrielc 81fea1d7ec Merge origin/main (185 commits) into feat/structured-skill-format
Conflict resolutions:
- 9 SKILL.mds we rewrote in the nc: format: kept ours, then bumped every
  @chat-adapter pin 4.26.0 -> 4.29.0 to match the merged chat core (the
  lockstep lint enforces equality; pin-test expectation updated).
- 14 setup/add-*.sh, install-*.sh + setup/channels/{slack,signal,whatsapp}.ts:
  stay deleted — replaced by the directive engine + runChannelSkill. Upstream's
  Slack Socket Mode (cf8478ff, setup-side) will be ported into add-slack's
  SKILL.md as a when:-guarded mode in a follow-up commit.
- src/cli/resources/groups.ts: fused create — upstream's --template branch
  first, else our idempotent-on-folder create with ensureContainerConfig.
- src/cli/registry.ts + dispatch.test.ts: both sides' additions kept
  (hostOnly field/doc + upstream's resource doc and approval-context fixture).

Known follow-ups (next commits): remove the now-dead nc:env-sync directive
(upstream removed the data/env/env mirror writers; the reader died pre-fork),
port Slack Socket Mode into the SKILL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:21:49 +03:00

143 lines
5.8 KiB
TypeScript

import { randomUUID } from 'crypto';
import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
import { routeInbound } from '../../router.js';
import type { MessagingGroup } from '../../types.js';
import { registerResource } from '../crud.js';
registerResource({
name: 'messaging-group',
plural: 'messaging-groups',
table: 'messaging_groups',
description:
'Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id, instance) triple, which must be unique.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{
name: 'channel_type',
type: 'string',
description:
'Channel adapter type — matches the adapter registered by /add-<channel> (e.g. telegram, discord, slack, whatsapp).',
required: true,
},
{
name: 'platform_id',
type: 'string',
description:
'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.',
required: true,
},
{
name: 'instance',
type: 'string',
description:
'Adapter instance that owns this chat, when running N adapters of one channel type. Defaults to channel_type (the default instance) when omitted.',
defaultFrom: 'channel_type',
updatable: true,
},
{
name: 'name',
type: 'string',
description: 'Display name. Often auto-populated by the channel adapter.',
updatable: true,
},
{
name: 'is_group',
type: 'number',
description: 'Multi-user group chat (1) or direct message (0). Affects session scoping.',
default: 0,
updatable: true,
},
{
name: 'unknown_sender_policy',
type: 'string',
description:
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.',
enum: ['strict', 'request_approval', 'public'],
default: 'strict',
updatable: true,
},
{
name: 'denied_at',
type: 'string',
description:
'Set when the owner explicitly denies registering this channel. While set, the router drops all messages silently without re-escalating. Cleared by any explicit wiring mutation.',
updatable: true,
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
// Generic create is replaced by the custom `create` below — the standard
// INSERT can't default the NOT NULL `instance` column (it isn't in the
// resource's column list) and isn't idempotent.
operations: { list: 'open', get: 'open', update: 'approval', delete: 'approval' },
customOperations: {
create: {
access: 'approval',
description:
'Create (or return the existing) messaging group. Idempotent on (channel_type, platform_id, instance); instance defaults to channel_type. Use --channel-type, --platform-id, optionally --instance, --name, --is-group, --unknown-sender-policy.',
handler: async (args) => {
const channelType = args.channel_type as string;
const platformId = args.platform_id as string;
if (!channelType || !platformId) {
throw new Error('--channel-type and --platform-id are required');
}
const instance = (args.instance as string) ?? channelType;
const existing = getMessagingGroupByPlatform(channelType, platformId, instance);
if (existing) return existing;
const group = {
id: randomUUID(),
channel_type: channelType,
platform_id: platformId,
instance,
name: (args.name as string) ?? null,
is_group: Number(args.is_group ?? 0),
unknown_sender_policy: (args.unknown_sender_policy as string) ?? 'strict',
denied_at: null,
created_at: new Date().toISOString(),
} as MessagingGroup;
createMessagingGroup(group);
return group;
},
},
send: {
access: 'approval',
description:
'Inject a message into a messaging group as if a sender posted it, waking the wired agent — used to send a welcome on first wire. Use --channel-type, --platform-id, --text, optionally --instance, --sender-id, --sender.',
handler: async (args) => {
const channelType = args.channel_type as string;
const platformId = args.platform_id as string;
const text = args.text as string;
if (!channelType || !platformId || !text) {
throw new Error('--channel-type, --platform-id and --text are required');
}
const instance = (args.instance as string) ?? channelType;
const mg = getMessagingGroupByPlatform(channelType, platformId, instance);
if (!mg) {
throw new Error(`no messaging group for ${channelType} ${platformId} — create + wire it first`);
}
// Build the same InboundEvent the CLI admin transport (src/channels/cli.ts)
// emits for a routed message, and route it in-process. The sender id should
// be a wired user (e.g. the owner just granted) so the access gate passes.
await routeInbound({
channelType,
instance,
platformId,
threadId: platformId,
message: {
id: `send-${randomUUID()}`,
kind: 'chat',
timestamp: new Date().toISOString(),
content: JSON.stringify({
text,
sender: (args.sender as string) ?? 'cli',
senderId: (args.sender_id as string) ?? 'cli:local',
}),
},
});
return { sent: { channel_type: channelType, platform_id: platformId } };
},
},
},
});