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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,8 @@ KIT_MATTERMOST_URL=http://100.68.122.24:8065
KIT_MATTERMOST_TOKEN=
KIT_MATTERMOST_USER_ID=zdunkip7xjy1ukn9xd8wt5kqrc
JORDAN_MATTERMOST_USER_ID=zh5bhphuqfdtffks1nys4e76ie
LEXI_MATTERMOST_USER_ID=7satyicgxpfq5grdsuty9g3cuw
KIT_MATTERMOST_TEAM=dhoh8n1xkjfgzy7s63ufckb9ko
KIT_MATTERMOST_CHANNEL=3r1tnxhe5fgcmjxgrspm7316oc
KIT_MATTERMOST_DM=3r1tnxhe5fgcmjxgrspm7316oc
KIT_MATTERMOST_HALLWAY=9sp18hgiaiybtj9p5nm8uw3sna
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ This is Lexi's `instructions()` idea (identity + domain + boot + scratch + missi
| `YouTubeTranscript` | Full captions via `yt-dlp` (timestamps). Not Lexi's 8k clip. |
| `MemoryStore` / `MemorySearch` | File-backed `knowledge_kilt`. |
| `AskLexi` | Shells to `~/.grok/kit/ask-lexi` (her MCP). Taste / life only. |
| `ChannelCreate` | Find or create a Mattermost open channel. `php artisan kit:channel` / `mm create`. |

Next (not built): `BlenderRun`, `LookCompare` (run Playwright), `GitHubOpenIssue`.

Expand Down
2 changes: 2 additions & 0 deletions app/Agent/KitAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Tools\AskLexi;
use App\Tools\BoardWrite;
use App\Tools\CatalogRead;
use App\Tools\ChannelCreate;
use App\Tools\HeartbeatRead;
use App\Tools\LookReport;
use App\Tools\MemorySearch;
Expand All @@ -28,6 +29,7 @@ public function tools(): iterable
return [
new CatalogRead,
new HeartbeatRead,
new ChannelCreate,
new BoardWrite,
new LookReport,
new YouTubeTranscript,
Expand Down
45 changes: 45 additions & 0 deletions app/Console/Commands/ChannelCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Console\Commands;

use App\Mattermost\Client;
use Illuminate\Console\Command;

class ChannelCommand extends Command
{
protected $signature = 'kit:channel
{name : Channel slug}
{--display= : Display name}
{--purpose= : Purpose}
{--add= : Comma @users or ids}';

protected $description = 'Find or create a Mattermost open channel';

public function handle(Client $mm): int
{
$out = $mm->createChannel(
(string) $this->argument('name'),
(string) $this->option('display'),
(string) $this->option('purpose'),
);
if (isset($out['error'])) {
$this->error($out['error']);

return self::FAILURE;
}

$add = trim((string) $this->option('add'));
if ($add !== '') {
foreach (preg_split('/\s*,\s*/', $add) ?: [] as $who) {
$uid = $mm->resolveUser($who);
if ($uid !== null) {
$mm->addMember($out['id'], $uid);
}
}
}

$this->line((($out['created'] ?? false) ? 'created' : 'exists').' '.$out['id']);

return self::SUCCESS;
}
}
110 changes: 100 additions & 10 deletions app/Mattermost/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,117 @@

namespace App\Mattermost;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class Client
{
public function post(string $channel, string $text): void
{
$url = rtrim((string) config('kit.mattermost.url'), '/');
$token = (string) config('kit.mattermost.token');
if ($url === '' || $token === '' || $channel === '') {
if ($this->base() === null || $channel === '') {
Log::warning('kit mm post skipped: missing url/token/channel');

return;
}

Http::timeout(20)
->withToken($token)
->acceptJson()
->post($url.'/api/v4/posts', [
'channel_id' => $channel,
'message' => $text,
]);
$this->http()->post($this->base().'/api/v4/posts', [
'channel_id' => $channel,
'message' => $text,
]);
}

/**
* Find or create an open team channel. Returns id + created flag.
*
* @return array{id: string, name: string, created: bool}|array{error: string}
*/
public function createChannel(string $name, string $display = '', string $purpose = ''): array
{
$slug = $this->slug($name);
if ($slug === '') {
return ['error' => 'channel name required'];
}
$base = $this->base();
$team = (string) config('kit.mattermost.team_id');
if ($base === null || $team === '') {
return ['error' => 'mattermost url/token/team missing'];
}

$existing = $this->http()->get($base.'/api/v4/teams/'.$team.'/channels/name/'.$slug);
if ($existing->successful() && is_string($existing->json('id'))) {
return ['id' => $existing->json('id'), 'name' => $slug, 'created' => false];
}

$res = $this->http()->post($base.'/api/v4/channels', [
'team_id' => $team,
'name' => $slug,
'display_name' => $display !== '' ? $display : $slug,
'purpose' => $purpose,
'type' => 'O',
]);
if (! $res->successful() || ! is_string($res->json('id'))) {
return ['error' => 'create failed: '.($res->json('message') ?? $res->status())];
}

return ['id' => $res->json('id'), 'name' => $slug, 'created' => true];
}

public function addMember(string $channel, string $userId): bool
{
$base = $this->base();
if ($base === null || $channel === '' || $userId === '') {
return false;
}
$res = $this->http()->post($base.'/api/v4/channels/'.$channel.'/members', [
'user_id' => $userId,
]);

return $res->successful();
}

public function resolveUser(string $who): ?string
{
$who = ltrim(trim($who), '@');
if ($who === '') {
return null;
}
$base = $this->base();
if ($base === null) {
return null;
}
if (strlen($who) === 26 && ctype_alnum($who)) {
return $who;
}
$res = $this->http()->get($base.'/api/v4/users/username/'.$who);
$id = $res->json('id');

return is_string($id) && $id !== '' ? $id : null;
}

private function slug(string $name): string
{
$name = strtolower(trim($name));
$name = ltrim($name, '#');

return preg_replace('/[^a-z0-9-]/', '-', $name) ?? '';
}

private function http(): PendingRequest
{
return Http::timeout(20)
->withToken((string) config('kit.mattermost.token'))
->acceptJson();
}

private function base(): ?string
{
$url = rtrim((string) config('kit.mattermost.url'), '/');
$token = (string) config('kit.mattermost.token');
if ($url === '' || $token === '') {
return null;
}

return $url;
}
}
59 changes: 59 additions & 0 deletions app/Tools/ChannelCreate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace App\Tools;

use App\Mattermost\Client;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

readonly class ChannelCreate implements Tool
{
public function description(): Stringable|string
{
return 'Find or create a Mattermost open channel on the factory team. '
.'Pass name (slug). Optional display, purpose, and add (comma @users or ids). '
.'Returns channel id. Use for hallway channels. Does not post as a second mouth.';
}

public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->description('Channel slug, e.g. mesa-studio.')->required(),
'display' => $schema->string()->description('Optional display name.'),
'purpose' => $schema->string()->description('Optional purpose line.'),
'add' => $schema->string()->description('Optional comma @usernames or user ids to add.'),
];
}

public function handle(Request $request): Stringable|string
{
$name = trim((string) ($request['name'] ?? ''));
$mm = app(Client::class);
$out = $mm->createChannel(
$name,
trim((string) ($request['display'] ?? '')),
trim((string) ($request['purpose'] ?? '')),
);
if (isset($out['error'])) {
return 'channel: '.$out['error'];
}

$added = [];
$add = trim((string) ($request['add'] ?? ''));
if ($add !== '') {
foreach (preg_split('/\s*,\s*/', $add) ?: [] as $who) {
$uid = $mm->resolveUser($who);
if ($uid !== null && $mm->addMember($out['id'], $uid)) {
$added[] = $who;
}
}
}

$flag = ($out['created'] ?? false) ? 'created' : 'exists';

return $flag.' #'.$out['name'].' id='.$out['id']
.($added !== [] ? ' added='.implode(',', $added) : '');
}
}
4 changes: 4 additions & 0 deletions config/kit.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
'token' => env('KIT_MATTERMOST_TOKEN', ''),
'user_id' => env('KIT_MATTERMOST_USER_ID', 'zdunkip7xjy1ukn9xd8wt5kqrc'),
'jordan_user_id' => env('JORDAN_MATTERMOST_USER_ID', 'zh5bhphuqfdtffks1nys4e76ie'),
'lexi_user_id' => env('LEXI_MATTERMOST_USER_ID', '7satyicgxpfq5grdsuty9g3cuw'),
'team_id' => env('KIT_MATTERMOST_TEAM', 'dhoh8n1xkjfgzy7s63ufckb9ko'),
'channel_id' => env('KIT_MATTERMOST_CHANNEL', '3r1tnxhe5fgcmjxgrspm7316oc'),
'hallway_id' => env('KIT_MATTERMOST_HALLWAY', '9sp18hgiaiybtj9p5nm8uw3sna'),
'dm_id' => env('KIT_MATTERMOST_DM', '3r1tnxhe5fgcmjxgrspm7316oc'),
],
];
57 changes: 57 additions & 0 deletions tests/Feature/ChannelCreateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

use App\Mattermost\Client;
use App\Tools\ChannelCreate;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Tools\Request;

beforeEach(function () {
config([
'kit.mattermost.url' => 'http://mm.test',
'kit.mattermost.token' => 'tok',
'kit.mattermost.team_id' => 'team-1',
]);
});

test('returns existing channel without creating', function () {
Http::fake([
'http://mm.test/api/v4/teams/team-1/channels/name/mesa-studio' => Http::response([
'id' => 'ch-exists',
'name' => 'mesa-studio',
], 200),
]);

$out = (string) (new ChannelCreate)->handle(new Request(['name' => 'mesa-studio']));

expect($out)->toBe('exists #mesa-studio id=ch-exists');
Http::assertNotSent(fn ($req) => $req->url() === 'http://mm.test/api/v4/channels' && $req->method() === 'POST');
});

test('creates an open channel and adds members', function () {
Http::fake([
'http://mm.test/api/v4/teams/team-1/channels/name/shop-floor' => Http::response(['id' => 'missing'], 404),
'http://mm.test/api/v4/channels' => Http::response(['id' => 'ch-new', 'name' => 'shop-floor'], 201),
'http://mm.test/api/v4/users/username/jordan' => Http::response(['id' => 'u-j'], 200),
'http://mm.test/api/v4/channels/ch-new/members' => Http::response(['user_id' => 'u-j'], 201),
]);

$out = (string) (new ChannelCreate)->handle(new Request([
'name' => 'Shop Floor',
'purpose' => 'factory',
'add' => '@jordan',
]));

expect($out)->toBe('created #shop-floor id=ch-new added=@jordan');
});

test('client slugs names', function () {
Http::fake([
'http://mm.test/api/v4/teams/team-1/channels/name/mesa-studio' => Http::response([
'id' => 'ch-1',
'name' => 'mesa-studio',
], 200),
]);

$out = (new Client)->createChannel('#Mesa Studio');
expect($out)->toMatchArray(['id' => 'ch-1', 'created' => false, 'name' => 'mesa-studio']);
});
Loading