crewly 1.20.248 → 1.20.249
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/skills/agent/core/list-channels/SKILL.md +61 -0
- package/config/skills/agent/core/list-channels/execute.sh +36 -0
- package/config/skills/agent/core/list-channels/execute.test.sh +17 -0
- package/config/skills/agent/core/reply-channel/SKILL.md +10 -0
- package/config/skills/agent/core/reply-channel/execute.sh +23 -1
- package/config/skills/agent/core/reply-channel/execute.test.sh +25 -0
- package/dist/backend/backend/src/constants.d.ts +25 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +25 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/channels/channels.routes.d.ts +27 -0
- package/dist/backend/backend/src/controllers/channels/channels.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/channels/channels.routes.js +99 -0
- package/dist/backend/backend/src/controllers/channels/channels.routes.js.map +1 -0
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +18 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/channels/crewly-channel.service.d.ts +247 -0
- package/dist/backend/backend/src/services/channels/crewly-channel.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/channels/crewly-channel.service.js +631 -0
- package/dist/backend/backend/src/services/channels/crewly-channel.service.js.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +26 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +40 -2
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-channel-members.d.ts +60 -0
- package/dist/backend/backend/src/services/slack/slack-channel-members.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-channel-members.js +70 -0
- package/dist/backend/backend/src/services/slack/slack-channel-members.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js +2 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +147 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +423 -12
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts +10 -0
- package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.js +15 -0
- package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/backend/backend/src/types/crewly-channel.types.d.ts +77 -0
- package/dist/backend/backend/src/types/crewly-channel.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/crewly-channel.types.js +35 -0
- package/dist/backend/backend/src/types/crewly-channel.types.js.map +1 -0
- package/dist/backend/backend/src/types/slack.types.d.ts +7 -0
- package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/slack.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +25 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +25 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +26 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +40 -2
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-channel-members.d.ts +87 -0
- package/dist/cli/backend/src/services/slack/slack-channel-members.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-channel-members.js +124 -0
- package/dist/cli/backend/src/services/slack/slack-channel-members.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +147 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +423 -12
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts +10 -0
- package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.js +15 -0
- package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/cli/backend/src/types/slack.types.d.ts +7 -0
- package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/slack.types.js.map +1 -1
- package/frontend/dist/assets/{index-8f4d482e.js → index-2db17f1e.js} +291 -291
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -373,6 +373,8 @@ export class SlackTeamChannelService {
|
|
|
373
373
|
humanNames = new Map();
|
|
374
374
|
/** Whether the owner's names were looked up yet. */
|
|
375
375
|
ownerNamesLoaded = false;
|
|
376
|
+
/** The owner's display name for posts made on their behalf. */
|
|
377
|
+
ownerName = null;
|
|
376
378
|
/** channelId → members, with fetch time; decides between same-named agents. */
|
|
377
379
|
channelMembers = new Map();
|
|
378
380
|
/**
|
|
@@ -440,7 +442,10 @@ export class SlackTeamChannelService {
|
|
|
440
442
|
// in the room (2026-10-03 / 2026-10-04, #content-team). Check against
|
|
441
443
|
// Slack now and periodically.
|
|
442
444
|
if (this.deps.listChannelMembers) {
|
|
443
|
-
const run = () => void this.pruneAdhocMembers()
|
|
445
|
+
const run = () => void this.pruneAdhocMembers()
|
|
446
|
+
.catch((err) => this.logger.warn('Ad-hoc room prune failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) }))
|
|
447
|
+
.then(() => this.syncRoomsFromSlack())
|
|
448
|
+
.catch((err) => this.logger.warn('Room sync from Slack failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) }));
|
|
444
449
|
this.pruneTimer = setTimeout(run, SLACK_TEAM_CHANNEL_CONSTANTS.ADHOC_PRUNE_FIRST_DELAY_MS);
|
|
445
450
|
this.pruneInterval = setInterval(run, SLACK_TEAM_CHANNEL_CONSTANTS.ADHOC_PRUNE_INTERVAL_MS);
|
|
446
451
|
this.pruneTimer.unref?.();
|
|
@@ -652,6 +657,14 @@ export class SlackTeamChannelService {
|
|
|
652
657
|
}
|
|
653
658
|
return result;
|
|
654
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Whether Slack is connected right now (creating a channel needs it).
|
|
662
|
+
*
|
|
663
|
+
* @returns True when connected
|
|
664
|
+
*/
|
|
665
|
+
isConnected() {
|
|
666
|
+
return this.deps.slack.isConnected();
|
|
667
|
+
}
|
|
655
668
|
/** Undo {@link start}. */
|
|
656
669
|
stop() {
|
|
657
670
|
if (!this.started)
|
|
@@ -952,12 +965,13 @@ export class SlackTeamChannelService {
|
|
|
952
965
|
*/
|
|
953
966
|
async ensureAgentChannel(input) {
|
|
954
967
|
const store = await this.load();
|
|
955
|
-
const derived = slackChannelNameFor(input.name, store.channelPrefix);
|
|
968
|
+
const derived = slackChannelNameFor(input.name, input.applyPrefix === false ? '' : store.channelPrefix);
|
|
956
969
|
return this.serialised(`agent-channel:${derived}`, async () => {
|
|
957
970
|
if (!this.deps.slack.isConnected())
|
|
958
971
|
throw new Error('Slack is not connected');
|
|
959
972
|
const current = await this.load();
|
|
960
973
|
let mapping = (input.existingChannelId ? current.mappings.find((m) => m.slackChannelId === input.existingChannelId) : undefined) ??
|
|
974
|
+
(input.chatChannelId ? current.mappings.find((m) => m.chatChannelId === input.chatChannelId) : undefined) ??
|
|
961
975
|
current.mappings.find((m) => isAdhocMapping(m) && m.autoCreated && (m.derivedName ?? m.slackChannelName) === derived);
|
|
962
976
|
const sessions = [...new Set(input.memberSessions.filter((s) => !!s))];
|
|
963
977
|
if (mapping) {
|
|
@@ -971,18 +985,32 @@ export class SlackTeamChannelService {
|
|
|
971
985
|
}
|
|
972
986
|
else {
|
|
973
987
|
const channel = await this.deps.slack.createChannel(derived);
|
|
988
|
+
// createChannel hands back an existing channel when the name is taken;
|
|
989
|
+
// a team's channel must not be turned into a second room.
|
|
990
|
+
const clash = current.mappings.find((m) => m.slackChannelId === channel.id);
|
|
991
|
+
if (clash)
|
|
992
|
+
throw new Error(`#${channel.name} is already linked to ${isAdhocMapping(clash) ? 'another channel' : 'a team'}`);
|
|
974
993
|
const ownerInvited = await this.inviteOwner(channel.id, channel.name);
|
|
975
994
|
if (input.purpose.trim()) {
|
|
976
995
|
await this.deps.slack.setChannelPurpose(channel.id, input.purpose.trim()).catch((err) => {
|
|
977
996
|
this.logger.debug('setPurpose failed (non-critical)', { error: err instanceof Error ? err.message : String(err) });
|
|
978
997
|
});
|
|
979
998
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
999
|
+
let huddle;
|
|
1000
|
+
if (input.chatChannelId) {
|
|
1001
|
+
huddle = { id: input.chatChannelId };
|
|
1002
|
+
if (sessions.length > 0)
|
|
1003
|
+
this.deps.chat.setHuddleMembers(input.chatChannelId, sessions);
|
|
1004
|
+
this.deps.chat.renameChannelForBridge?.(input.chatChannelId, `#${channel.name}`);
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
huddle = this.deps.chat.createHuddle({
|
|
1008
|
+
name: `#${channel.name}`,
|
|
1009
|
+
purpose: input.purpose.trim() || `Slack channel #${channel.name}`,
|
|
1010
|
+
memberSessions: sessions.length > 0 ? sessions : [`channel:${channel.id}`],
|
|
1011
|
+
principal: { userId: 'system', source: 'oss' },
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
986
1014
|
mapping = {
|
|
987
1015
|
teamId: `${SLACK_TEAM_CHANNEL_CONSTANTS.ADHOC_TEAM_PREFIX}${channel.id}`,
|
|
988
1016
|
slackChannelId: channel.id,
|
|
@@ -1275,7 +1303,7 @@ export class SlackTeamChannelService {
|
|
|
1275
1303
|
// owner had added to a private channel out of it until someone happened
|
|
1276
1304
|
// to address it by name.
|
|
1277
1305
|
const receiving = await this.localReceivingAgent(message);
|
|
1278
|
-
if (receiving && isAdhocMapping(mapping) && !(mapping.members ?? []).includes(receiving)) {
|
|
1306
|
+
if (receiving && isAdhocMapping(mapping) && !(mapping.members ?? []).includes(receiving) && !(mapping.excluded ?? []).includes(receiving)) {
|
|
1279
1307
|
mapping.members = [...(mapping.members ?? []), receiving];
|
|
1280
1308
|
this.deps.chat.setHuddleMembers(mapping.chatChannelId, mapping.members);
|
|
1281
1309
|
await this.save();
|
|
@@ -1492,6 +1520,12 @@ export class SlackTeamChannelService {
|
|
|
1492
1520
|
const next = [...new Set([...(mapping.members ?? []), ...resolved.mentions])];
|
|
1493
1521
|
if (next.length !== (mapping.members ?? []).length) {
|
|
1494
1522
|
mapping.members = next;
|
|
1523
|
+
// An explicit @ brings back an agent the owner had removed.
|
|
1524
|
+
if (mapping.excluded) {
|
|
1525
|
+
mapping.excluded = mapping.excluded.filter((sess) => !next.includes(sess));
|
|
1526
|
+
if (mapping.excluded.length === 0)
|
|
1527
|
+
delete mapping.excluded;
|
|
1528
|
+
}
|
|
1495
1529
|
this.deps.chat.setHuddleMembers(mapping.chatChannelId, next);
|
|
1496
1530
|
await this.save();
|
|
1497
1531
|
this.deps.onRoomsChanged?.();
|
|
@@ -2999,6 +3033,12 @@ export class SlackTeamChannelService {
|
|
|
2999
3033
|
});
|
|
3000
3034
|
return false;
|
|
3001
3035
|
};
|
|
3036
|
+
if (dto.senderType === 'user' && isOwnerTypedInCrewly(dto)) {
|
|
3037
|
+
await this.load();
|
|
3038
|
+
const room = this.findByChatChannelId(dto.channelId);
|
|
3039
|
+
if (room && isAdhocMapping(room))
|
|
3040
|
+
return await this.mirrorOwnerPost(room, dto);
|
|
3041
|
+
}
|
|
3002
3042
|
if (dto.senderType !== 'agent')
|
|
3003
3043
|
return skip(`senderType=${dto.senderType}`);
|
|
3004
3044
|
if (dto.metadata?.source === 'slack')
|
|
@@ -3073,6 +3113,90 @@ export class SlackTeamChannelService {
|
|
|
3073
3113
|
return false;
|
|
3074
3114
|
}
|
|
3075
3115
|
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Post a message the owner typed in Crewly into the room's Slack channel,
|
|
3118
|
+
* so people and agents on other machines see it. Posted by the workspace
|
|
3119
|
+
* (Crewly) bot as `*<owner>* (from Crewly): …`; in a private room the
|
|
3120
|
+
* workspace bot is not in, a member's bot posts it instead. Threads follow
|
|
3121
|
+
* the chat thread's Slack root. The Slack copy is remembered as already
|
|
3122
|
+
* routed, so its echo is never dispatched a second time (the chat-v2
|
|
3123
|
+
* dispatcher already delivered the original).
|
|
3124
|
+
*
|
|
3125
|
+
* @param mapping - The room (ad-hoc mapping)
|
|
3126
|
+
* @param dto - The owner's chat row
|
|
3127
|
+
* @returns True when Slack took it
|
|
3128
|
+
*/
|
|
3129
|
+
async mirrorOwnerPost(mapping, dto) {
|
|
3130
|
+
if (!this.deps.slack.isConnected())
|
|
3131
|
+
return false;
|
|
3132
|
+
const pendingRoot = dto.threadId ? this.rootPosts.get(dto.threadId) : undefined;
|
|
3133
|
+
if (pendingRoot)
|
|
3134
|
+
await pendingRoot;
|
|
3135
|
+
const threadTs = this.resolveOutboundThreadTs(mapping, dto);
|
|
3136
|
+
if (threadTs === null)
|
|
3137
|
+
return false;
|
|
3138
|
+
let done = () => undefined;
|
|
3139
|
+
if (!threadTs)
|
|
3140
|
+
this.rootPosts.set(dto.id, new Promise((resolve) => (done = resolve)));
|
|
3141
|
+
try {
|
|
3142
|
+
const owner = await this.ownerDisplayName();
|
|
3143
|
+
const body = await this.linkAgentMentions(toSlackMrkdwn(dto.content), mapping.slackChannelId);
|
|
3144
|
+
const text = `*${owner}* (from Crewly): ${body}`;
|
|
3145
|
+
const send = (botToken) => this.deps.slack.sendMessage({
|
|
3146
|
+
channelId: mapping.slackChannelId,
|
|
3147
|
+
text,
|
|
3148
|
+
...(threadTs ? { threadTs } : {}),
|
|
3149
|
+
skipChatV2Mirror: true,
|
|
3150
|
+
notAnAnswer: true,
|
|
3151
|
+
...(botToken ? { botToken } : {}),
|
|
3152
|
+
});
|
|
3153
|
+
let ts = '';
|
|
3154
|
+
let lastError = '';
|
|
3155
|
+
if (this.deps.identities)
|
|
3156
|
+
await this.deps.identities.load();
|
|
3157
|
+
for (const token of [undefined, ...this.memberTokens(mapping)]) {
|
|
3158
|
+
try {
|
|
3159
|
+
ts = await send(token);
|
|
3160
|
+
break;
|
|
3161
|
+
}
|
|
3162
|
+
catch (err) {
|
|
3163
|
+
lastError = describeSlackError(err).code;
|
|
3164
|
+
if (lastError !== 'not_in_channel' && lastError !== 'channel_not_found')
|
|
3165
|
+
break;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
if (!ts) {
|
|
3169
|
+
this.logger.warn('Owner message from Crewly not posted to Slack', { slackChannel: mapping.slackChannelName, error: lastError });
|
|
3170
|
+
return false;
|
|
3171
|
+
}
|
|
3172
|
+
// The echo of this post (through any app in the room) is a repeat.
|
|
3173
|
+
this.seenInbound.set(`${mapping.slackChannelId}:${ts}`, dto);
|
|
3174
|
+
if (!threadTs)
|
|
3175
|
+
this.recordSlackRoot(dto, mapping, ts);
|
|
3176
|
+
this.logger.info('Owner message from Crewly posted to Slack', { slackChannel: mapping.slackChannelName, threaded: Boolean(threadTs) });
|
|
3177
|
+
return true;
|
|
3178
|
+
}
|
|
3179
|
+
finally {
|
|
3180
|
+
done();
|
|
3181
|
+
this.rootPosts.delete(dto.id);
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
/** The owner's Slack name (cached), or "Owner". */
|
|
3185
|
+
async ownerDisplayName() {
|
|
3186
|
+
if (this.ownerName)
|
|
3187
|
+
return this.ownerName;
|
|
3188
|
+
const owner = this.deps.getOwnerUserId?.() ?? null;
|
|
3189
|
+
if (owner && this.deps.slack.getUserInfo) {
|
|
3190
|
+
try {
|
|
3191
|
+
const info = await this.deps.slack.getUserInfo(owner);
|
|
3192
|
+
this.ownerName = info.realName || info.name || null;
|
|
3193
|
+
}
|
|
3194
|
+
catch {
|
|
3195
|
+
/* fall through */
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
return this.ownerName ?? 'Owner';
|
|
3199
|
+
}
|
|
3076
3200
|
/**
|
|
3077
3201
|
* Post an agent message that belongs to no thread as a new top-level
|
|
3078
3202
|
* Slack post.
|
|
@@ -3813,9 +3937,11 @@ export class SlackTeamChannelService {
|
|
|
3813
3937
|
continue;
|
|
3814
3938
|
await this.inviteBot(mapping, agentSession, record.botUserId);
|
|
3815
3939
|
}
|
|
3816
|
-
//
|
|
3940
|
+
// Rooms the agent is a member of: channels Crewly created for a set of
|
|
3941
|
+
// agents (ensureAgentChannel), and Slack channels the owner added it to
|
|
3942
|
+
// from Crewly before its bot existed.
|
|
3817
3943
|
for (const mapping of this.store?.mappings ?? []) {
|
|
3818
|
-
if (!isAdhocMapping(mapping)
|
|
3944
|
+
if (!isAdhocMapping(mapping))
|
|
3819
3945
|
continue;
|
|
3820
3946
|
if (!(mapping.members ?? []).includes(agentSession) || record.invitedTo.includes(mapping.slackChannelId))
|
|
3821
3947
|
continue;
|
|
@@ -3829,7 +3955,11 @@ export class SlackTeamChannelService {
|
|
|
3829
3955
|
}
|
|
3830
3956
|
catch (err) {
|
|
3831
3957
|
const message = err instanceof Error ? err.message : String(err);
|
|
3832
|
-
|
|
3958
|
+
const code = describeSlackError(err).code;
|
|
3959
|
+
const alreadyIn = /already_in_channel/.test(message) || code === 'already_in_channel';
|
|
3960
|
+
// The workspace bot is usually not in a private room the owner made;
|
|
3961
|
+
// another member's own bot can invite instead.
|
|
3962
|
+
if (!alreadyIn && !(await this.inviteViaMemberBot(mapping, agentSession, botUserId))) {
|
|
3833
3963
|
this.logger.warn('Could not invite agent bot into channel', { agentSession, channel: mapping.slackChannelName, error: message });
|
|
3834
3964
|
return false;
|
|
3835
3965
|
}
|
|
@@ -3838,6 +3968,272 @@ export class SlackTeamChannelService {
|
|
|
3838
3968
|
this.logger.info('Agent bot invited into team channel', { agentSession, channel: `#${mapping.slackChannelName}` });
|
|
3839
3969
|
return true;
|
|
3840
3970
|
}
|
|
3971
|
+
/**
|
|
3972
|
+
* Invite a bot with another room member's own bot token (the workspace bot
|
|
3973
|
+
* is not in the room). Tries each member that has a bot until one works.
|
|
3974
|
+
*
|
|
3975
|
+
* @returns True when the bot is now in the channel
|
|
3976
|
+
*/
|
|
3977
|
+
async inviteViaMemberBot(mapping, agentSession, botUserId) {
|
|
3978
|
+
const ops = this.deps.channelOps;
|
|
3979
|
+
if (!ops || !isAdhocMapping(mapping))
|
|
3980
|
+
return false;
|
|
3981
|
+
for (const token of this.memberTokens(mapping, [agentSession])) {
|
|
3982
|
+
const res = await ops.invite(mapping.slackChannelId, botUserId, token);
|
|
3983
|
+
if (res.ok || res.error === 'already_in_channel')
|
|
3984
|
+
return true;
|
|
3985
|
+
}
|
|
3986
|
+
return false;
|
|
3987
|
+
}
|
|
3988
|
+
/** Installed bot tokens of an ad-hoc room's members, minus `except`. */
|
|
3989
|
+
memberTokens(mapping, except = []) {
|
|
3990
|
+
return [
|
|
3991
|
+
...new Set((mapping.members ?? [])
|
|
3992
|
+
.filter((s) => !except.includes(s))
|
|
3993
|
+
.map((s) => this.deps.identities?.getInstalled(s)?.botToken)
|
|
3994
|
+
.filter((t) => !!t)),
|
|
3995
|
+
];
|
|
3996
|
+
}
|
|
3997
|
+
/**
|
|
3998
|
+
* Take an agent's bot out of a room's Slack channel: the workspace bot
|
|
3999
|
+
* removes it (`conversations.kick`), else the agent's own bot leaves.
|
|
4000
|
+
*
|
|
4001
|
+
* @returns True when the bot is out (or was never in)
|
|
4002
|
+
*/
|
|
4003
|
+
async removeBot(mapping, agentSession) {
|
|
4004
|
+
const installed = this.deps.identities?.getInstalled(agentSession) ?? null;
|
|
4005
|
+
if (!installed)
|
|
4006
|
+
return false;
|
|
4007
|
+
if (this.deps.slack.kickFromChannel && this.deps.slack.isConnected()) {
|
|
4008
|
+
try {
|
|
4009
|
+
await this.deps.slack.kickFromChannel(mapping.slackChannelId, installed.botUserId);
|
|
4010
|
+
return true;
|
|
4011
|
+
}
|
|
4012
|
+
catch (err) {
|
|
4013
|
+
if (describeSlackError(err).code === 'not_in_channel')
|
|
4014
|
+
return true;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
const ops = this.deps.channelOps;
|
|
4018
|
+
if (!ops)
|
|
4019
|
+
return false;
|
|
4020
|
+
const res = await ops.leave(mapping.slackChannelId, installed.botToken);
|
|
4021
|
+
return res.ok || res.error === 'not_in_channel' || res.error === 'channel_not_found';
|
|
4022
|
+
}
|
|
4023
|
+
// -------------------------------------------------------------------------
|
|
4024
|
+
// Crewly channels (specs/2026-10-07-crewly-channels.md): ad-hoc rooms
|
|
4025
|
+
// managed from Crewly — members, name, archive — and kept in step with Slack.
|
|
4026
|
+
// -------------------------------------------------------------------------
|
|
4027
|
+
/**
|
|
4028
|
+
* Set an ad-hoc room's members from Crewly. Added agents' bots are invited
|
|
4029
|
+
* into the Slack channel; removed agents' bots are taken out. Removed
|
|
4030
|
+
* agents are remembered (`excluded`) so they are not added back
|
|
4031
|
+
* automatically if Slack refused to take their bot out.
|
|
4032
|
+
*
|
|
4033
|
+
* @param slackChannelId - The room's Slack channel
|
|
4034
|
+
* @param sessions - The complete desired roster
|
|
4035
|
+
* @returns The diff and what Slack did with it
|
|
4036
|
+
* @throws Error when the channel is not an ad-hoc room
|
|
4037
|
+
*/
|
|
4038
|
+
async setRoomMembers(slackChannelId, sessions) {
|
|
4039
|
+
return this.serialised(`room:${slackChannelId}`, async () => {
|
|
4040
|
+
await this.load();
|
|
4041
|
+
const mapping = this.findBySlackChannelId(slackChannelId);
|
|
4042
|
+
if (!mapping || !isAdhocMapping(mapping))
|
|
4043
|
+
throw new Error(`Not a Crewly channel: ${slackChannelId}`);
|
|
4044
|
+
const wanted = [...new Set(sessions.map((x) => x.trim()).filter(Boolean))];
|
|
4045
|
+
const before = mapping.members ?? [];
|
|
4046
|
+
const added = wanted.filter((x) => !before.includes(x));
|
|
4047
|
+
const removed = before.filter((x) => !wanted.includes(x));
|
|
4048
|
+
const change = { added, removed, invited: [], notInvited: [], removedFromSlack: [], stillInSlack: [] };
|
|
4049
|
+
if (added.length === 0 && removed.length === 0)
|
|
4050
|
+
return change;
|
|
4051
|
+
if (this.deps.identities)
|
|
4052
|
+
await this.deps.identities.load();
|
|
4053
|
+
// Remove first, with the roster as it was: a leaving bot's token can
|
|
4054
|
+
// still help, and the remaining members' tokens can invite.
|
|
4055
|
+
for (const session of removed) {
|
|
4056
|
+
(await this.removeBot(mapping, session) ? change.removedFromSlack : change.stillInSlack).push(session);
|
|
4057
|
+
}
|
|
4058
|
+
mapping.members = wanted;
|
|
4059
|
+
const excluded = new Set([...(mapping.excluded ?? []).filter((x) => !added.includes(x)), ...removed]);
|
|
4060
|
+
if (excluded.size > 0)
|
|
4061
|
+
mapping.excluded = [...excluded];
|
|
4062
|
+
else
|
|
4063
|
+
delete mapping.excluded;
|
|
4064
|
+
this.deps.chat.setHuddleMembers(mapping.chatChannelId, wanted);
|
|
4065
|
+
await this.save();
|
|
4066
|
+
for (const session of added) {
|
|
4067
|
+
const record = this.deps.identities?.get(session);
|
|
4068
|
+
const ok = record?.status === 'installed' && record.botUserId ? await this.inviteBot(mapping, session, record.botUserId) : false;
|
|
4069
|
+
(ok ? change.invited : change.notInvited).push(session);
|
|
4070
|
+
}
|
|
4071
|
+
this.deps.onRoomsChanged?.();
|
|
4072
|
+
this.logger.info('Channel members changed from Crewly', { slackChannel: `#${mapping.slackChannelName}`, ...change });
|
|
4073
|
+
return change;
|
|
4074
|
+
});
|
|
4075
|
+
}
|
|
4076
|
+
/**
|
|
4077
|
+
* Rename an ad-hoc room's Slack channel from Crewly. The name is
|
|
4078
|
+
* normalised the way Slack wants it (lower case, no spaces, at most 80
|
|
4079
|
+
* characters); the name Slack applied is stored and returned, so Crewly
|
|
4080
|
+
* shows exactly what Slack shows.
|
|
4081
|
+
*
|
|
4082
|
+
* @param slackChannelId - The room's Slack channel
|
|
4083
|
+
* @param name - The name the owner typed
|
|
4084
|
+
* @returns The applied channel name (without `#`)
|
|
4085
|
+
* @throws Error when the room is unknown or Slack refused (name taken, no permission)
|
|
4086
|
+
*/
|
|
4087
|
+
async renameRoom(slackChannelId, name) {
|
|
4088
|
+
return this.serialised(`room:${slackChannelId}`, async () => {
|
|
4089
|
+
await this.load();
|
|
4090
|
+
const mapping = this.findBySlackChannelId(slackChannelId);
|
|
4091
|
+
if (!mapping || !isAdhocMapping(mapping))
|
|
4092
|
+
throw new Error(`Not a Crewly channel: ${slackChannelId}`);
|
|
4093
|
+
const desired = slackChannelNameFor(name);
|
|
4094
|
+
if (desired === mapping.slackChannelName)
|
|
4095
|
+
return desired;
|
|
4096
|
+
let applied = null;
|
|
4097
|
+
let lastError = 'rename_failed';
|
|
4098
|
+
if (this.deps.slack.isConnected())
|
|
4099
|
+
applied = await this.deps.slack.renameChannel(slackChannelId, desired).catch(() => null);
|
|
4100
|
+
if (!applied && this.deps.channelOps) {
|
|
4101
|
+
if (this.deps.identities)
|
|
4102
|
+
await this.deps.identities.load();
|
|
4103
|
+
for (const token of this.memberTokens(mapping)) {
|
|
4104
|
+
const res = await this.deps.channelOps.rename(slackChannelId, desired, token);
|
|
4105
|
+
if (res.ok) {
|
|
4106
|
+
applied = res.name;
|
|
4107
|
+
break;
|
|
4108
|
+
}
|
|
4109
|
+
lastError = res.error;
|
|
4110
|
+
if (res.error === 'name_taken' || res.error === 'invalid_name')
|
|
4111
|
+
break;
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
if (!applied)
|
|
4115
|
+
throw new Error(`Slack did not rename #${mapping.slackChannelName}: ${lastError}`);
|
|
4116
|
+
this.applyRoomName(mapping, applied);
|
|
4117
|
+
await this.save();
|
|
4118
|
+
this.logger.info('Channel renamed from Crewly', { slackChannelId, to: applied });
|
|
4119
|
+
return applied;
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
/**
|
|
4123
|
+
* Archive an ad-hoc room from Crewly: the mapping and huddle go; the Slack
|
|
4124
|
+
* channel is archived when `archiveSlackChannel`, otherwise the members'
|
|
4125
|
+
* bots leave it (so it is not found again as a room).
|
|
4126
|
+
*
|
|
4127
|
+
* @param slackChannelId - The room's Slack channel
|
|
4128
|
+
* @param options - `archiveSlackChannel` for a channel Crewly created
|
|
4129
|
+
* @returns True when a room existed
|
|
4130
|
+
*/
|
|
4131
|
+
async archiveRoom(slackChannelId, options = {}) {
|
|
4132
|
+
const mapping = this.findBySlackChannelId(slackChannelId);
|
|
4133
|
+
if (!mapping || !isAdhocMapping(mapping))
|
|
4134
|
+
return false;
|
|
4135
|
+
if (!options.archiveSlackChannel) {
|
|
4136
|
+
if (this.deps.identities)
|
|
4137
|
+
await this.deps.identities.load();
|
|
4138
|
+
for (const session of mapping.members ?? [])
|
|
4139
|
+
await this.removeBot(mapping, session);
|
|
4140
|
+
}
|
|
4141
|
+
const done = await this.unlinkTeam(mapping.teamId, { archiveSlackChannel: !!options.archiveSlackChannel });
|
|
4142
|
+
if (done)
|
|
4143
|
+
this.deps.onRoomsChanged?.();
|
|
4144
|
+
return done;
|
|
4145
|
+
}
|
|
4146
|
+
/**
|
|
4147
|
+
* Bring names and ad-hoc rosters in from Slack.
|
|
4148
|
+
*
|
|
4149
|
+
* - Every mapped channel (team and ad-hoc): a rename in Slack is shown in
|
|
4150
|
+
* Crewly (mapping name and huddle name). A team channel's `derivedName`
|
|
4151
|
+
* is left alone, so the owner's Slack name is not renamed back on the
|
|
4152
|
+
* next team save.
|
|
4153
|
+
* - Ad-hoc rooms: local agents whose bot is in the Slack channel join the
|
|
4154
|
+
* roster, unless the owner removed them in Crewly. (Leaving is handled by
|
|
4155
|
+
* {@link pruneAdhocMembers}.)
|
|
4156
|
+
*
|
|
4157
|
+
* Reads with the room members' own bots first (the workspace bot is
|
|
4158
|
+
* usually not in a private room), then the workspace bot. Nothing changes
|
|
4159
|
+
* on a read that fails.
|
|
4160
|
+
*
|
|
4161
|
+
* @returns What changed
|
|
4162
|
+
*/
|
|
4163
|
+
async syncRoomsFromSlack() {
|
|
4164
|
+
await this.load();
|
|
4165
|
+
const result = { renamed: [], joined: [] };
|
|
4166
|
+
if (this.deps.identities)
|
|
4167
|
+
await this.deps.identities.load();
|
|
4168
|
+
const teams = await this.deps.storage.getTeams();
|
|
4169
|
+
const local = teams
|
|
4170
|
+
.flatMap((t) => teamChannelMembers(t))
|
|
4171
|
+
.filter((m) => this.deps.isLocalAgent?.(m.sessionName) ?? true);
|
|
4172
|
+
let changed = false;
|
|
4173
|
+
let roomsChanged = false;
|
|
4174
|
+
for (const mapping of this.store?.mappings ?? []) {
|
|
4175
|
+
let liveName = null;
|
|
4176
|
+
let inChannel = null;
|
|
4177
|
+
if (isAdhocMapping(mapping)) {
|
|
4178
|
+
for (const token of this.memberTokens(mapping)) {
|
|
4179
|
+
if (!liveName && this.deps.channelOps) {
|
|
4180
|
+
const info = await this.deps.channelOps.info(mapping.slackChannelId, token);
|
|
4181
|
+
if (info.ok && info.name)
|
|
4182
|
+
liveName = info.name;
|
|
4183
|
+
}
|
|
4184
|
+
if (!inChannel && this.deps.listChannelMembers) {
|
|
4185
|
+
const list = await this.deps.listChannelMembers(mapping.slackChannelId, token);
|
|
4186
|
+
if (list.ok)
|
|
4187
|
+
inChannel = new Set(list.members);
|
|
4188
|
+
}
|
|
4189
|
+
if (liveName && (inChannel || !this.deps.listChannelMembers))
|
|
4190
|
+
break;
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
if (!liveName && this.deps.slack.isConnected()) {
|
|
4194
|
+
const info = await this.deps.slack.getChannelInfo(mapping.slackChannelId).catch(() => null);
|
|
4195
|
+
if (info?.name)
|
|
4196
|
+
liveName = info.name;
|
|
4197
|
+
}
|
|
4198
|
+
if (liveName && liveName !== mapping.slackChannelName) {
|
|
4199
|
+
result.renamed.push({ slackChannelId: mapping.slackChannelId, from: mapping.slackChannelName, to: liveName });
|
|
4200
|
+
this.applyRoomName(mapping, liveName);
|
|
4201
|
+
changed = true;
|
|
4202
|
+
}
|
|
4203
|
+
if (inChannel && isAdhocMapping(mapping)) {
|
|
4204
|
+
const members = mapping.members ?? [];
|
|
4205
|
+
const excluded = mapping.excluded ?? [];
|
|
4206
|
+
const joined = local
|
|
4207
|
+
.filter((m) => !members.includes(m.sessionName) && !excluded.includes(m.sessionName))
|
|
4208
|
+
.filter((m) => {
|
|
4209
|
+
const botUserId = this.deps.identities?.getInstalled(m.sessionName)?.botUserId;
|
|
4210
|
+
return !!botUserId && inChannel.has(botUserId);
|
|
4211
|
+
})
|
|
4212
|
+
.map((m) => m.sessionName);
|
|
4213
|
+
if (joined.length > 0) {
|
|
4214
|
+
mapping.members = [...members, ...joined];
|
|
4215
|
+
this.deps.chat.setHuddleMembers(mapping.chatChannelId, mapping.members);
|
|
4216
|
+
result.joined.push({ slackChannelId: mapping.slackChannelId, added: joined });
|
|
4217
|
+
changed = true;
|
|
4218
|
+
roomsChanged = true;
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
if (changed)
|
|
4223
|
+
await this.save();
|
|
4224
|
+
if (roomsChanged)
|
|
4225
|
+
this.deps.onRoomsChanged?.();
|
|
4226
|
+
if (result.renamed.length || result.joined.length)
|
|
4227
|
+
this.logger.info('Rooms synced from Slack', { ...result });
|
|
4228
|
+
return result;
|
|
4229
|
+
}
|
|
4230
|
+
/** Record a channel's Slack name on its mapping and huddle (`#name`). */
|
|
4231
|
+
applyRoomName(mapping, name) {
|
|
4232
|
+
mapping.slackChannelName = name;
|
|
4233
|
+
if (isAdhocMapping(mapping) && mapping.autoCreated)
|
|
4234
|
+
mapping.derivedName = name;
|
|
4235
|
+
this.deps.chat.renameChannelForBridge?.(mapping.chatChannelId, `#${name}`);
|
|
4236
|
+
}
|
|
3841
4237
|
// -------------------------------------------------------------------------
|
|
3842
4238
|
// Slack-side messages
|
|
3843
4239
|
// -------------------------------------------------------------------------
|
|
@@ -3897,6 +4293,21 @@ export class SlackTeamChannelService {
|
|
|
3897
4293
|
return next;
|
|
3898
4294
|
}
|
|
3899
4295
|
}
|
|
4296
|
+
/**
|
|
4297
|
+
* A row the owner typed in Crewly (dashboard, phone, Talk) — not a Slack
|
|
4298
|
+
* message recorded here and not an agent writing as a user turn.
|
|
4299
|
+
*
|
|
4300
|
+
* @param dto - A `user` chat row
|
|
4301
|
+
* @returns True when it should be mirrored to the room's Slack channel
|
|
4302
|
+
*/
|
|
4303
|
+
export function isOwnerTypedInCrewly(dto) {
|
|
4304
|
+
const md = dto.metadata ?? {};
|
|
4305
|
+
if (md.source !== undefined && md.source !== 'cloud-talk')
|
|
4306
|
+
return false;
|
|
4307
|
+
if (md[OWNER_EVIDENCE_METADATA.AUTHOR_AGENT_SESSION] || md[OWNER_EVIDENCE_METADATA.REMOTE_AGENT_SESSION])
|
|
4308
|
+
return false;
|
|
4309
|
+
return typeof md.slackTs !== 'string' && typeof md.slackChannelId !== 'string';
|
|
4310
|
+
}
|
|
3900
4311
|
/** Type guard for persisted mapping rows. */
|
|
3901
4312
|
/**
|
|
3902
4313
|
* A promise's value, or `fallback` when it takes longer than `ms`.
|