Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,16 @@ You can link a private Telegram group to a ride so that participants are automat

**Note:** Telegram does not allow bots to add users directly — participants receive a private invite link instead.

### Joining the Ride Group Chat

Once a group is attached, the ride message displays a notice with instructions. Any participant who has joined the ride can request an invite link by sending the bot a private message:

```
/joinchat #rideId
```

The bot sends a single-use invite link valid for 24 hours. The command only works if you have joined the ride.

## Route Support

The bot supports route links from:
Expand Down
3 changes: 2 additions & 1 deletion SPECIFICATION.MD
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ All handlers extend **[BaseCommandHandler](cci:2://file:///workspace/src/command
- **ListRidesCommandHandler**: Paginated list of user's rides
- **ListParticipantsCommandHandler**: List all participants for a specific ride (shows all without truncation, organized by participation state)
- **ParticipationHandlers**: Join/thinking/pass ride functionality; triggers group membership sync via `GroupManagementService` when a group is attached to the ride
- **GroupCommandHandler**: `/attach #rideId` (links a group to a ride, posts and pins the ride message, adds existing participants) and `/detach` (unlinks the group); both commands are group-chat-only
- **GroupCommandHandler**: `/attach #rideId` (links a group to a ride, posts and pins the ride message, adds existing participants, updates all existing ride messages), `/detach` (unlinks the group, updates all existing ride messages), and `/joinchat #rideId` (private-only: sends an invite link to the ride's group chat if the user has joined the ride); attach/detach are group-chat-only
- **StartCommandHandler**: Welcome message
- **HelpCommandHandler**: Multi-page help system
- **FromStravaCommandHandler**: Import or update a ride from a Strava club event URL; uses `StravaEventParser` to fetch event data and maps it to ride fields; repeated calls with the same URL by the same user update the existing ride
Expand All @@ -149,6 +149,7 @@ All handlers extend **[BaseCommandHandler](cci:2://file:///workspace/src/command
- Formats ride lists with pagination
- Handles date/time formatting with timezone support
- **Share line for creators**: Shows "Share this ride: `/shareride #ID`" for ride creators in private chats
- **Group chat line**: When a group is attached to a ride (`ride.groupId` is set), shows a notice with `/joinchat #ID` instructions in all ride messages; line is absent (no extra whitespace) when no group is attached
- Groups ride details logically

### **8. Utilities ([/src/utils/](cci:7://file:///workspace/src/utils:0:0-0:0))**
Expand Down
168 changes: 168 additions & 0 deletions docs/changes/joinchat-group-chat-line-specification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Specification: /joinchat Command and Group Chat Line in Ride Messages

## Overview

This change extends the group attachment feature with two improvements:

1. **Group chat line in ride messages** — when a group is attached to a ride, all ride messages (in all chats) display a notice telling participants how to join the group chat. The line disappears when the group is detached. Attaching or detaching a group triggers an update of all existing ride messages.

2. **`/joinchat` command** — a new private-only command that lets a ride participant request an invite link to the ride's group chat directly from the bot.

---

## Ride Message: Group Chat Line

### When shown

The line appears **only when `ride.groupId` is set**. When no group is attached, the line is absent with no extra blank lines.

### Placement

After the participants section (joined / thinking / not interested), before the footer (`🎫 #Ride #ID`), with one blank line between the participants section and the notice, and one blank line between the notice and the footer:

```
🙅 Not interested: 3

Join the ride's private group chat: send /joinchat #abc123 to the bot in private messages (only works if you have joined the ride).

🎫 #Ride #abc123
```

Without a group:

```
🙅 Not interested: 3

🎫 #Ride #abc123
```

### Template change

The `templates.ride` i18n key gets a `{groupChatLine}` placeholder inserted between the blank line and `{shareLine}`:

```
{groupChatLine}{shareLine}🎫 #Ride #{id}{cancelledInstructions}
```

### Formatter logic (`MessageFormatter.formatRideMessage`)

```javascript
const groupChatLine = ride.groupId
? `${this.translate('formatter.groupChatLine', { id: ride.id }, language)}\n\n`
: '';
message = message.replace('{groupChatLine}', groupChatLine);
```

### Message update on attach / detach

- **`/attach`**: after saving `groupId`, all pre-existing ride messages are updated via `RideMessagesService.updateRideMessages`. The new message posted in the group is created with the `groupId` already set, so it immediately shows the notice.
- **`/detach`**: after clearing `groupId`, all ride messages are updated to remove the notice.

Both updates are best-effort (wrapped in try/catch with logging).

---

## `/joinchat` Command

### Context

**Private chat only.** (Registered in `Bot.js` under `privateOnly` commands.)

### Arguments

`rideId` — the ride ID, optionally prefixed with `#`. Parsed via the existing `RideMessagesService.extractRideId` utility.

### Success flow

1. Parse ride ID from command text
2. Fetch ride by ID — error if not found
3. Check `ride.groupId` is set — error if not
4. Check caller (`ctx.from.id`) is in `ride.participation.joined` — error if not
5. Call `GroupManagementService.addParticipant(ctx.api, ride.groupId, ctx.from.id, ctx.lang, ride.createdBy)`
- Unbans the user (so previously-kicked users can accept a fresh link)
- Creates a single-use invite link (24-hour expiry)
- Sends the link to the user via DM
- If the DM fails with 403 (user hasn't started the bot): notifies the ride creator instead

No reply is sent by the handler itself; `addParticipant` handles all messaging.

### Error cases

| Condition | i18n key |
|---|---|
| Ride ID missing or invalid | `commands.group.invalidRideIdUsage` |
| Ride not found | `commands.group.rideNotFound` |
| Ride has no attached group | `commands.group.joinchatNoGroup` |
| Caller not in `joined` list | `commands.group.joinchatNotParticipant` |

---

## i18n Keys

### New keys

#### `formatter.groupChatLine`

| Locale | Value |
|---|---|
| EN | `Join the ride's private group chat: send <code>/joinchat #{id}</code> to the bot in private messages (only works if you have joined the ride).` |
| RU | `Присоединяйтесь к закрытой группе поездки: напишите <code>/joinchat #{id}</code> боту в личные сообщения (работает только если вы записались в поездку).` |

#### `commands.group.joinchatNoGroup`

| Locale | Value |
|---|---|
| EN | `This ride doesn't have an attached group chat.` |
| RU | `К этой поездке не привязана группа.` |

#### `commands.group.joinchatNotParticipant`

| Locale | Value |
|---|---|
| EN | `You need to join the ride first.` |
| RU | `Сначала нужно записаться в поездку.` |

#### `bot.commandDescriptions.joinchat`

| Locale | Value |
|---|---|
| EN | `Join the private group chat for a ride` |
| RU | `Войти в закрытую группу поездки` |

### Updated `help2` template

Both locales get a new **💬 Joining the Ride Group Chat** section appended after the group attachment section.

---

## Implementation Details

### `GroupCommandHandler.handleAttach` changes

- Builds a local `rideWithGroupId = { ...ride, groupId }` after `updateRide` so that `createRideMessage` uses the ride object with `groupId` set (ensuring the new group message immediately shows the notice)
- After the participant-add loop, fetches the latest ride from storage and calls `updateRideMessages` (best-effort)

### `GroupCommandHandler.handleDetach` changes

- After `updateRide({ groupId: null })`, calls `updateRideMessages({ ...ride, groupId: null }, ctx)` (best-effort) before sending the success reply

### `GroupCommandHandler.handleJoinChat` (new method)

Delegates to `GroupManagementService.addParticipant` — no new membership logic is introduced; the same invite-link mechanism used by `ParticipationHandlers` is reused.

---

## Files Changed

| File | Change |
|---|---|
| `src/i18n/locales/en.js` | Add `formatter.groupChatLine`, `commands.group.joinchatNoGroup`, `commands.group.joinchatNotParticipant`, `bot.commandDescriptions.joinchat`; add `{groupChatLine}` to `templates.ride`; update `help2` |
| `src/i18n/locales/ru.js` | Same as EN |
| `src/formatters/MessageFormatter.js` | Compute and replace `{groupChatLine}` in `formatRideMessage` |
| `src/commands/GroupCommandHandler.js` | Update `handleAttach` (use `rideWithGroupId`, call `updateRideMessages` after attach); update `handleDetach` (call `updateRideMessages` before reply); add `handleJoinChat` |
| `src/core/Bot.js` | Register `/joinchat` as a `privateOnly` command |
| `src/__tests__/commands/group-command-handler.test.js` | Update attach/detach assertions; add `handleJoinChat` test suite |
| `src/__tests__/core/bot.test.js` | Update private command count (11 → 12) |
| `README.md` | Add "Joining the Ride Group Chat" section |
| `SPECIFICATION.MD` | Update `GroupCommandHandler` entry; add group chat line to formatter description |
| `docs/changes/joinchat-group-chat-line-specification.md` | This document |
73 changes: 71 additions & 2 deletions src/__tests__/commands/group-command-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,20 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => {
expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.botNeedsAddMembersPermission'));
});

it('should attach group, post message, pin it, and reply success', async () => {
it('should attach group, post message, pin it, update messages, and reply success', async () => {
mockRideService.getRide.mockResolvedValue(makeRide());

await handler.handleAttach(mockCtx);

expect(mockRideService.updateRide).toHaveBeenCalledWith(RIDE_ID, { groupId: GROUP_ID });
expect(mockRideMessagesService.createRideMessage).toHaveBeenCalled();
expect(mockRideMessagesService.createRideMessage).toHaveBeenCalledWith(
expect.objectContaining({ id: RIDE_ID, groupId: GROUP_ID }),
mockCtx,
null
);
expect(mockCtx.api.pinChatMessage).toHaveBeenCalledWith(GROUP_ID, 777, { disable_notification: true });
expect(mockRideService.getRide).toHaveBeenCalledTimes(2);
expect(mockRideMessagesService.updateRideMessages).toHaveBeenCalled();
expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.attachSuccess'));
});

Expand Down Expand Up @@ -226,6 +232,10 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => {
await handler.handleDetach(mockCtx);

expect(mockRideService.updateRide).toHaveBeenCalledWith(RIDE_ID, { groupId: null });
expect(mockRideMessagesService.updateRideMessages).toHaveBeenCalledWith(
expect.objectContaining({ id: RIDE_ID, groupId: null }),
mockCtx
);
expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.detachSuccess'));
});

Expand All @@ -237,6 +247,10 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => {
await handler.handleDetach(mockCtx);

expect(mockRideService.updateRide).toHaveBeenCalledWith(RIDE_ID, { groupId: null });
expect(mockRideMessagesService.updateRideMessages).toHaveBeenCalledWith(
expect.objectContaining({ id: RIDE_ID, groupId: null }),
mockCtx
);
expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.detachSuccess'));
});

Expand All @@ -251,4 +265,59 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => {
expect(mockRideService.updateRide).not.toHaveBeenCalled();
});
});

// ─── /joinchat tests ──────────────────────────────────────────────────────────

describe('handleJoinChat', () => {
beforeEach(() => {
mockCtx.chat.type = 'private';
mockCtx.message.text = `/joinchat #${RIDE_ID}`;
});

it('should reply if ride not found', async () => {
mockRideService.getRide.mockResolvedValue(null);

await handler.handleJoinChat(mockCtx);

expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.rideNotFound'));
expect(mockGroupManagementService.addParticipant).not.toHaveBeenCalled();
});

it('should reply if ride has no attached group', async () => {
mockRideService.getRide.mockResolvedValue(makeRide({ groupId: null }));

await handler.handleJoinChat(mockCtx);

expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.joinchatNoGroup'));
expect(mockGroupManagementService.addParticipant).not.toHaveBeenCalled();
});

it('should reply if user has not joined the ride', async () => {
mockRideService.getRide.mockResolvedValue(makeRide({ groupId: GROUP_ID }));

await handler.handleJoinChat(mockCtx);

expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.joinchatNotParticipant'));
expect(mockGroupManagementService.addParticipant).not.toHaveBeenCalled();
});

it('should call addParticipant if user has joined the ride', async () => {
const ride = makeRide({
groupId: GROUP_ID,
participation: {
joined: [{ userId: CREATOR_ID, firstName: 'Creator', lastName: '', username: 'creator' }],
thinking: [],
skipped: []
}
});
mockRideService.getRide.mockResolvedValue(ride);

await handler.handleJoinChat(mockCtx);

expect(mockGroupManagementService.addParticipant).toHaveBeenCalledWith(
mockCtx.api, GROUP_ID, CREATOR_ID, language, CREATOR_ID
);
expect(mockCtx.reply).not.toHaveBeenCalled();
});
});
});
2 changes: 1 addition & 1 deletion src/__tests__/core/bot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe('Bot', () => {
expect(bot.botConfig.callbacks).toBeInstanceOf(Array);

// Check that commands are properly configured
expect(bot.botConfig.commands.privateOnly.length).toBe(12); // 12 private commands
expect(bot.botConfig.commands.privateOnly.length).toBe(13); // 13 private commands
expect(bot.botConfig.commands.mixed.length).toBe(1); // 1 mixed command (shareride)
expect(bot.botConfig.callbacks.length).toBe(7); // 7 callback handlers
});
Expand Down
51 changes: 50 additions & 1 deletion src/commands/GroupCommandHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class GroupCommandHandler extends BaseCommandHandler {

// Save groupId to ride
await this.rideService.updateRide(rideId, { groupId });
const rideWithGroupId = { ...ride, groupId };

// Rename the group (best-effort)
try {
Expand All @@ -100,7 +101,7 @@ export class GroupCommandHandler extends BaseCommandHandler {

// Post and pin the ride message in the group
try {
const { sentMessage } = await this.rideMessagesService.createRideMessage(ride, ctx, ctx.message?.message_thread_id);
const { sentMessage } = await this.rideMessagesService.createRideMessage(rideWithGroupId, ctx, ctx.message?.message_thread_id);
if (sentMessage?.message_id) {
try {
await ctx.api.pinChatMessage(groupId, sentMessage.message_id, { disable_notification: true });
Expand All @@ -118,9 +119,52 @@ export class GroupCommandHandler extends BaseCommandHandler {
await this.groupManagementService.addParticipant(ctx.api, groupId, participant.userId, ctx.lang, ride.createdBy);
}

// Update all pre-existing ride messages to show the group chat line (best-effort)
try {
const latestRide = await this.rideService.getRide(rideId);
if (latestRide) await this.rideMessagesService.updateRideMessages(latestRide, ctx);
} catch (updateError) {
console.error('GroupCommandHandler: failed to update ride messages after attach:', updateError);
}

await ctx.reply(this.translate(ctx, 'commands.group.attachSuccess'));
}

/**
* Handle /joinchat #rideId — sends an invite link to join the ride's group chat
* @param {import('grammy').Context} ctx
*/
async handleJoinChat(ctx) {
const { rideId, error } = this.rideMessagesService.extractRideId(ctx.message, ctx.lang ? { language: ctx.lang } : {});
if (!rideId) {
await ctx.reply(error || this.translate(ctx, 'commands.group.invalidRideIdUsage'));
return;
}

let ride;
try {
ride = await this.rideService.getRide(rideId);
} catch (e) {
ride = null;
}
if (!ride) {
await ctx.reply(this.translate(ctx, 'commands.group.rideNotFound'));
return;
}
if (!ride.groupId) {
await ctx.reply(this.translate(ctx, 'commands.group.joinchatNoGroup'));
return;
}

const isParticipant = (ride.participation?.joined || []).some(p => p.userId === ctx.from.id);
if (!isParticipant) {
await ctx.reply(this.translate(ctx, 'commands.group.joinchatNotParticipant'));
return;
}

await this.groupManagementService.addParticipant(ctx.api, ride.groupId, ctx.from.id, ctx.lang, ride.createdBy);
}

/**
* Handle /detach — unlinks the current group from its ride
* @param {import('grammy').Context} ctx
Expand Down Expand Up @@ -162,6 +206,11 @@ export class GroupCommandHandler extends BaseCommandHandler {
}

await this.rideService.updateRide(ride.id, { groupId: null });
try {
await this.rideMessagesService.updateRideMessages({ ...ride, groupId: null }, ctx);
} catch (updateError) {
console.error('GroupCommandHandler: failed to update ride messages after detach:', updateError);
}
await ctx.reply(this.translate(ctx, 'commands.group.detachSuccess'));
}
}
Loading
Loading