bigbluebutton-api-ruby 0.0.11 → 0.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. data/.gitignore +5 -2
  2. data/.rvmrc +1 -1
  3. data/.travis.yml +6 -0
  4. data/CHANGELOG.rdoc +19 -8
  5. data/Gemfile +9 -1
  6. data/Gemfile.lock +62 -9
  7. data/LICENSE +5 -7
  8. data/LICENSE_003 +20 -0
  9. data/README.rdoc +42 -19
  10. data/Rakefile +31 -19
  11. data/bigbluebutton-api-ruby.gemspec +5 -5
  12. data/examples/get_version_example.rb +18 -0
  13. data/examples/join_example.rb +59 -0
  14. data/examples/overall_0.7_example.rb +92 -0
  15. data/examples/prepare.rb +38 -0
  16. data/extras/bigbluebutton_bot.rb +64 -0
  17. data/extras/download_bot_from.txt +1 -0
  18. data/extras/test-presentation.pdf +0 -0
  19. data/features/check_status.feature +45 -0
  20. data/features/config.yml.example +21 -0
  21. data/features/create_meetings.feature +29 -0
  22. data/features/end_meetings.feature +27 -0
  23. data/features/join_meetings.feature +29 -0
  24. data/features/pre_upload_slides.feature +14 -0
  25. data/features/recordings.feature +34 -0
  26. data/features/step_definitions/check_status_steps.rb +119 -0
  27. data/features/step_definitions/common_steps.rb +122 -0
  28. data/features/step_definitions/create_meetings_steps.rb +54 -0
  29. data/features/step_definitions/end_meetings_steps.rb +49 -0
  30. data/features/step_definitions/join_meetings_steps.rb +39 -0
  31. data/features/step_definitions/pre_upload_slides_steps.rb +13 -0
  32. data/features/step_definitions/recordings_steps.rb +38 -0
  33. data/features/support/api_tests/configs.rb +51 -0
  34. data/features/support/env.rb +7 -0
  35. data/features/support/hooks.rb +11 -0
  36. data/lib/bigbluebutton_api.rb +301 -97
  37. data/lib/bigbluebutton_formatter.rb +105 -19
  38. data/lib/bigbluebutton_modules.rb +92 -0
  39. data/lib/hash_to_xml.rb +22 -51
  40. data/spec/bigbluebutton_api_0.8_spec.rb +273 -0
  41. data/spec/bigbluebutton_api_spec.rb +211 -117
  42. data/spec/bigbluebutton_formatter_spec.rb +178 -29
  43. data/spec/bigbluebutton_modules_spec.rb +95 -0
  44. data/spec/data/hash_to_xml_complex.xml +45 -0
  45. data/spec/hash_to_xml_spec.rb +143 -0
  46. data/spec/spec_helper.rb +4 -2
  47. data/spec/support/forgery/forgeries/random_name.rb +7 -0
  48. data/spec/support/forgery/forgeries/url.rb +5 -0
  49. metadata +47 -12
  50. data/test/config.yml.example +0 -9
  51. data/test/test.rb +0 -154
@@ -0,0 +1,92 @@
1
+ $:.unshift File.expand_path(File.dirname(__FILE__))
2
+ $:.unshift File.join(File.expand_path(File.dirname(__FILE__)), '..', 'lib')
3
+
4
+ require 'bigbluebutton_api'
5
+ require 'prepare'
6
+ require 'securerandom'
7
+
8
+ begin
9
+ prepare
10
+
11
+ puts
12
+ puts "---------------------------------------------------"
13
+ if @api.test_connection
14
+ puts "Connection successful! continuing..."
15
+ else
16
+ puts "Connection failed! The server might be unreachable. Exiting..."
17
+ Kernel.exit!
18
+ end
19
+
20
+ puts
21
+ puts "---------------------------------------------------"
22
+ version = @api.get_api_version
23
+ puts "The API version of your server is #{version}"
24
+
25
+ puts
26
+ puts "---------------------------------------------------"
27
+ response = @api.get_meetings
28
+ puts "Existent meetings in your server:"
29
+ response[:meetings].each do |m|
30
+ puts " " + m[:meetingID] + ": " + m.inspect
31
+ end
32
+
33
+ puts
34
+ puts "---------------------------------------------------"
35
+ meeting_id = SecureRandom.hex(4)
36
+ meeting_name = meeting_id
37
+ moderator_name = "House"
38
+ attendee_name = "Cameron"
39
+ options = { :moderatorPW => "54321",
40
+ :attendeePW => "12345",
41
+ :welcome => 'Welcome to my meeting',
42
+ :dialNumber => '1-800-000-0000x00000#',
43
+ :voiceBridge => 70000 + rand(9999),
44
+ :webVoice => SecureRandom.hex(4),
45
+ :logoutURL => 'https://github.com/mconf/bigbluebutton-api-ruby',
46
+ :maxParticipants => 25 }
47
+
48
+ @api.create_meeting(meeting_name, meeting_id, options)
49
+ puts "The meeting has been created. Please open a web browser and enter the meeting using either of the URLs below."
50
+
51
+ puts
52
+ puts "---------------------------------------------------"
53
+ url = @api.join_meeting_url(meeting_id, moderator_name, options[:moderatorPW])
54
+ puts "1) Moderator URL = #{url}"
55
+ puts ""
56
+ url = @api.join_meeting_url(meeting_id, attendee_name, options[:attendeePW])
57
+ puts "2) Attendee URL = #{url}"
58
+
59
+ puts
60
+ puts "---------------------------------------------------"
61
+ puts "Waiting 30 seconds for you to enter via browser"
62
+ sleep(30)
63
+
64
+ unless @api.is_meeting_running?(meeting_id)
65
+ puts "You have NOT entered the meeting"
66
+ Kernel.exit!
67
+ end
68
+ puts "You have successfully entered the meeting"
69
+
70
+ puts
71
+ puts "---------------------------------------------------"
72
+ response = @api.get_meeting_info(meeting_id, options[:moderatorPW])
73
+ puts "Meeting info:"
74
+ puts response.inspect
75
+
76
+ puts
77
+ puts "---------------------------------------------------"
78
+ puts "Attendees:"
79
+ response[:attendees].each do |m|
80
+ puts " " + m[:fullName] + " (" + m[:userID] + "): " + m.inspect
81
+ end
82
+
83
+
84
+ puts
85
+ puts "---------------------------------------------------"
86
+ @api.end_meeting(meeting_id, options[:moderatorPW])
87
+ puts "The meeting has been ended"
88
+
89
+ rescue Exception => ex
90
+ puts "Failed with error #{ex.message}"
91
+ puts ex.backtrace
92
+ end
@@ -0,0 +1,38 @@
1
+ $:.unshift File.expand_path(File.dirname(__FILE__))
2
+ $:.unshift File.join(File.expand_path(File.dirname(__FILE__)), '..', 'lib')
3
+
4
+ require 'bigbluebutton_api'
5
+ require 'yaml'
6
+
7
+ def prepare
8
+ config_file = File.join(File.dirname(__FILE__), '..', 'features', 'config.yml')
9
+ unless File.exist? config_file
10
+ puts config_file + " does not exists. Copy the example and configure your server."
11
+ puts "cp features/config.yml.example features/config.yml"
12
+ puts
13
+ Kernel.exit!
14
+ end
15
+ @config = YAML.load_file(config_file)
16
+
17
+ puts "** Config:"
18
+ @config.each do |k,v|
19
+ puts k + ": " + v.to_s
20
+ end
21
+ puts
22
+
23
+ if ARGV.size > 0
24
+ unless @config['servers'].has_key?(ARGV[0])
25
+ throw Exception.new("Server #{ARGV[0]} does not exists in your configuration file.")
26
+ end
27
+ server = @config['servers'][ARGV[0]]
28
+ else
29
+ key = @config['servers'].keys.first
30
+ server = @config['servers'][key]
31
+ end
32
+
33
+ puts "** Using the server:"
34
+ puts server.inspect
35
+ puts
36
+
37
+ @api = BigBlueButton::BigBlueButtonApi.new(server['url'], server['salt'], server['version'].to_s, true)
38
+ end
@@ -0,0 +1,64 @@
1
+ class BigBlueButtonBot
2
+ BOT_FILENAME = "bbb-bot.jar"
3
+ @@pids = []
4
+
5
+ def initialize(api, meeting, salt="", count=1, timeout=20)
6
+ bot_file = File.join(File.dirname(__FILE__), BOT_FILENAME)
7
+ unless File.exist?(bot_file)
8
+ throw Exception.new(bot_file + " does not exists. See download_bot_from.txt and download the bot file.")
9
+ end
10
+
11
+ server = parse_server_url(api.url)
12
+
13
+ # note: fork + exec with these parameters was the only solution found to run the command in background
14
+ # and be able to wait for it (kill it) later on (see BigBlueButtonBot.finalize)
15
+ pid = Process.fork do
16
+ exec("java", "-jar", "#{bot_file}", "-s", "#{server}", "-p", "#{salt}", "-m", "#{meeting}", "-n", "#{count}")
17
+
18
+ # other options that didn't work:
19
+ # IO::popen("java -jar #{bot_file} -s \"#{server}\" -m \"#{meeting}\" -n #{count} >/dev/null")
20
+ # exec(["java", "-jar #{bot_file} -s \"#{server}\" -m \"#{meeting}\" -n #{count} >/dev/null"])
21
+ # exec("java -jar #{bot_file} -s \"#{server}\" -m \"#{meeting}\" -n #{count} >/dev/null")
22
+ # Process.exit!
23
+ end
24
+ @@pids << pid
25
+
26
+ wait_bot_startup(api, meeting, count, timeout)
27
+ end
28
+
29
+ def self.finalize
30
+ @@pids.each do |pid|
31
+ p = Process.kill("TERM", pid)
32
+ Process.detach(pid)
33
+ end
34
+ @@pids.clear
35
+ end
36
+
37
+ def parse_server_url(full_url)
38
+ uri = URI.parse(full_url)
39
+ uri_s = uri.scheme + "://" + uri.host
40
+ uri_s = uri_s + ":" + uri.port.to_s if uri.port != uri.default_port
41
+ uri_s
42
+ end
43
+
44
+ # wait until the meeting is running with a certain number of participants
45
+ def wait_bot_startup(api, meeting, participants, timeout=20)
46
+ Timeout::timeout(timeout) do
47
+ stop_wait = false
48
+ while !stop_wait
49
+ sleep 1
50
+
51
+ # find the meeting and hope it is running
52
+ response = api.get_meetings
53
+ selected = response[:meetings].reject!{ |m| m[:meetingID] != meeting }
54
+ if selected and selected.size > 0 and selected[0][:running]
55
+
56
+ # check how many participants are in the meeting
57
+ pass = selected[0][:moderatorPW]
58
+ response = api.get_meeting_info(meeting, pass)
59
+ stop_wait = response[:participantCount] >= participants
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1 @@
1
+ https://github.com/daronco/mconf-mobile/blob/879f38e1be127f46b379c57a1933d3867b6bf14b/bbb-bot/bbb-bot.jar?raw=true
Binary file
@@ -0,0 +1,45 @@
1
+ Feature: Check meeting configurations and status
2
+ To be able to monitor BigBlueButton
3
+ One needs to check the current meetings
4
+ and the status and configurations of a meeting
5
+
6
+ @version-all @need-bot
7
+ Scenario: Check that a meeting is running
8
+ Given that a meeting was created
9
+ And the meeting is running
10
+ Then the method isMeetingRunning informs that the meeting is running
11
+
12
+ @version-all
13
+ Scenario: Check that a meeting is NOT running
14
+ Given that a meeting was created
15
+ Then the method isMeetingRunning informs that the meeting is NOT running
16
+
17
+ @version-all
18
+ Scenario: Check the information of a meeting
19
+ Given that a meeting was created
20
+ When calling the method get_meeting_info
21
+ Then the response is successful with no messages
22
+ And it shows all the information of the meeting that was created
23
+
24
+ # to make sure that getMeetingInfo is returning the proper info used in create
25
+ @version-all
26
+ Scenario: Check the information of a meeting created with optional parameters
27
+ Given that a meeting was created with ALL the optional arguments
28
+ When calling the method get_meeting_info
29
+ Then the response is successful with no messages
30
+ And it shows all the information of the meeting that was created
31
+
32
+ @version-all @need-bot
33
+ Scenario: Check the information of a meeting that is running and has attendees
34
+ Given that a meeting was created
35
+ And the meeting is running with 2 attendees
36
+ When calling the method get_meeting_info
37
+ Then the response is successful with no messages
38
+ And it shows the 2 attendees in the list
39
+
40
+ @version-all
41
+ Scenario: List the meetings in a server
42
+ Given that a meeting was created
43
+ When calling the method get_meetings
44
+ Then the response is successful with no messages
45
+ And the created meeting should be listed in the response with proper information
@@ -0,0 +1,21 @@
1
+ debug: false
2
+
3
+ # maximum wait for a response to any API request (secs)
4
+ timeout_req: 60
5
+
6
+ # maximum wait for a meeting to be ended after end_meeting is called (secs)
7
+ timeout_ending: 30
8
+
9
+ # maximum wait until the bot starts (secs)
10
+ timeout_bot_start: 60
11
+
12
+ servers:
13
+ bbb-dev-07:
14
+ salt: 'your-salt'
15
+ url: 'http://your-server/bigbluebutton/api'
16
+ version: '0.7'
17
+ test-install-08:
18
+ salt: '8cd8ef52e8e101574e400365b55e11a6'
19
+ url: 'http://test-install.blindsidenetworks.com/bigbluebutton/api'
20
+ version: '0.8'
21
+ mobile_salt: '03b07' # needed for >= 0.8
@@ -0,0 +1,29 @@
1
+ Feature: Create rooms
2
+ To be able to use BigBlueButton
3
+ One needs to create a webconference room first
4
+
5
+ @version-all
6
+ Scenario: Create a new room
7
+ When the create method is called with ALL the optional arguments
8
+ Then the response is successful and well formatted
9
+ And the meeting exists in the server
10
+
11
+ @version-all
12
+ Scenario: Create a new room with default parameters
13
+ When the create method is called with NO optional arguments
14
+ Then the response is successful and well formatted
15
+ And the meeting exists in the server
16
+
17
+ @version-all
18
+ Scenario: Try to create a room with a duplicated meeting id
19
+ When the create method is called with a duplicated meeting id
20
+ Then the response is an error with the key "idNotUnique"
21
+
22
+ @version-all @need-bot
23
+ Scenario: Try to recreate a previously ended meeting
24
+ Given the create method is called
25
+ # note: meeting needs to be running to be ended in 0.7
26
+ And the meeting is running
27
+ And the meeting is forcibly ended
28
+ When the create method is called again with the same meeting id
29
+ Then the response is an error with the key "idNotUnique"
@@ -0,0 +1,27 @@
1
+ Feature: End rooms
2
+ To stop a meeting using the API
3
+ One needs to be able to call 'end' to this meeting
4
+
5
+ @version-all @need-bot
6
+ Scenario: End a meeting
7
+ Given that a meeting was created
8
+ And the meeting is running
9
+ When the method to end the meeting is called
10
+ Then the response is successful and well formatted
11
+ And the meeting should be ended
12
+ And the information returned by get_meeting_info is correct
13
+
14
+ # in 0.7 ending a meeting that is not running generates an error
15
+ @version-07
16
+ Scenario: Try to end a meeting that is not running in 0.7
17
+ Given that a meeting was created
18
+ When the method to end the meeting is called
19
+ Then the response is an error with the key "notFound"
20
+
21
+ # in 0.8 ending a meeting that is not running is ok
22
+ @version-08
23
+ Scenario: Try to end a meeting that is not running in 0.8
24
+ Given that a meeting was created
25
+ When the method to end the meeting is called
26
+ Then the response is successful
27
+ And the response has the messageKey "sentEndMeetingRequest"
@@ -0,0 +1,29 @@
1
+ Feature: Join meeting
2
+ To participate in a meeting
3
+ The user needs to be able to join a created meeting
4
+
5
+ @version-all
6
+ Scenario: Join a meeting as moderator
7
+ Given that a meeting was created
8
+ When the user tries to access the link to join the meeting as moderator
9
+ Then he is redirected to the BigBlueButton client
10
+ # can't really check if the user is in the session because in bbb he will
11
+ # only be listed as an attendee after stabilishing a rtmp connection
12
+
13
+ @version-all
14
+ Scenario: Join a meeting as attendee
15
+ Given that a meeting was created
16
+ When the user tries to access the link to join the meeting as attendee
17
+ Then he is redirected to the BigBlueButton client
18
+
19
+ @version-all
20
+ Scenario: Join a non created meeting
21
+ Given the default BigBlueButton server
22
+ When the user tries to access the link to join a meeting that was not created
23
+ Then the response is an xml with the error "invalidMeetingIdentifier"
24
+
25
+ @version-all
26
+ Scenario: Try to join with the wrong password
27
+ Given that a meeting was created
28
+ When the user tries to access the link to join the meeting using a wrong password
29
+ Then the response is an xml with the error "invalidPassword"
@@ -0,0 +1,14 @@
1
+ @version-08
2
+ Feature: Pre-upload slides
3
+ To have presentations ready in the meeting when the users join
4
+ One needs to pre-upload these presentations when the meeting is created
5
+
6
+ Scenario: Pre-upload presentations
7
+ Given the default BigBlueButton server
8
+ When the user creates a meeting pre-uploading the following presentations:
9
+ | type | presentation |
10
+ | url | http://www.samplepdf.com/sample.pdf |
11
+ | file | extras/test-presentation.pdf |
12
+ Then the response is successful and well formatted
13
+ # OPTIMIZE: There's no way to check if the presentation is really in the meeting
14
+ # And these presentations should be available in the meeting as it begins
@@ -0,0 +1,34 @@
1
+ @version-08
2
+ Feature: Record a meeting and manage recordings
3
+ To record a meeting or manage the recorded meeting
4
+ One needs be able to list the recordings, publish and unpublish them
5
+
6
+ # We don't check if meetings will really be recorded
7
+ # To record a meeting we need at least audio in the session
8
+ # And also it would probably that a long time to record and process test meetings
9
+ # For now we'll have only basic tests in this feature
10
+
11
+ Scenario: Set a meeting to be recorded
12
+ Given the default BigBlueButton server
13
+ When the user creates a meeting with the record flag
14
+ Then the response is successful and well formatted
15
+ And the meeting is set to be recorded
16
+
17
+ Scenario: By default a meeting will not be recorded
18
+ Given the default BigBlueButton server
19
+ When the user creates a meeting without the record flag
20
+ Then the response is successful and well formatted
21
+ And the meeting is NOT set to be recorded
22
+
23
+ Scenario: List the available recordings in a server with no recordings
24
+ Given the default BigBlueButton server
25
+ When the user calls the get_recordings method
26
+ Then the response is successful and well formatted
27
+ And the response has the messageKey "noRecordings"
28
+
29
+ # Possible scenarios to test in the future
30
+ # Scenario: Record a meeting # not only set to be recorded
31
+ # Scenario: List the available recordings
32
+ # Scenario: Publish a recording
33
+ # Scenario: Unpublish a recording
34
+ # Scenario: Remove a recording
@@ -0,0 +1,119 @@
1
+ When /^the method isMeetingRunning informs that the meeting is running$/ do
2
+ @req.response = @api.is_meeting_running?(@req.id)
3
+ @req.response.should be_true
4
+ end
5
+
6
+ When /^the method isMeetingRunning informs that the meeting is not running$/i do
7
+ @req.response = @api.is_meeting_running?(@req.id)
8
+ @req.response.should be_false
9
+ end
10
+
11
+ When /^calling the method get_meetings$/ do
12
+ @req.response = @api.get_meetings
13
+ end
14
+
15
+ When /^calling the method get_meeting_info$/ do
16
+ @req.response = @api.get_meeting_info(@req.id, @req.mod_pass)
17
+ end
18
+
19
+ When /^the created meeting should be listed in the response with proper information$/ do
20
+ @req.response[:meetings].size.should >= 1
21
+
22
+ # the created meeting is in the list and has only 1 occurance
23
+ found = @req.response[:meetings].reject{ |m| m[:meetingID] != @req.id }
24
+ found.should_not be_nil
25
+ found.size.should == 1
26
+
27
+ # proper information in the meeting hash
28
+ found = found[0]
29
+ found[:attendeePW].should be_a(String)
30
+ found[:attendeePW].should_not be_empty
31
+ found[:moderatorPW].should == @req.mod_pass
32
+ found[:hasBeenForciblyEnded].should be_false
33
+ found[:running].should be_false
34
+ if @api.version >= "0.8"
35
+ found[:meetingName].should == @req.id
36
+ found[:createTime].should be_a(Numeric)
37
+ end
38
+ end
39
+
40
+ When /^it shows all the information of the meeting that was created$/ do
41
+ @req.response = @api.get_meeting_info(@req.id, @req.mod_pass)
42
+ @req.response[:meetingID].should == @req.id
43
+ @req.response[:running].should be_false
44
+ @req.response[:hasBeenForciblyEnded].should be_false
45
+ @req.response[:startTime].should be_nil
46
+ @req.response[:endTime].should be_nil
47
+ @req.response[:participantCount].should == 0
48
+ @req.response[:moderatorCount].should == 0
49
+ @req.response[:attendees].should == []
50
+ @req.response[:messageKey].should == ""
51
+ @req.response[:message].should == ""
52
+ if @req.opts.has_key?(:attendeePW)
53
+ @req.response[:attendeePW].should == @req.opts[:attendeePW]
54
+ else # auto generated password
55
+ @req.response[:attendeePW].should be_a(String)
56
+ @req.response[:attendeePW].should_not be_empty
57
+ @req.opts[:attendeePW] = @req.response[:attendeePW]
58
+ end
59
+ if @req.opts.has_key?(:moderatorPW)
60
+ @req.response[:moderatorPW].should == @req.opts[:moderatorPW]
61
+ else # auto generated password
62
+ @req.response[:moderatorPW].should be_a(String)
63
+ @req.response[:moderatorPW].should_not be_empty
64
+ @req.opts[:moderatorPW] = @req.response[:moderatorPW]
65
+ end
66
+
67
+ if @api.version >= "0.8"
68
+ @req.response[:meetingName].should == @req.id
69
+ @req.response[:createTime].should be_a(Numeric)
70
+
71
+ @req.opts.has_key?(:record) ?
72
+ (@req.response[:recording].should == @req.opts[:record]) :
73
+ (@req.response[:recording].should be_false)
74
+ @req.opts.has_key?(:maxParticipants) ?
75
+ (@req.response[:maxUsers].should == @req.opts[:maxParticipants]) :
76
+ (@req.response[:maxUsers].should == 20)
77
+ @req.opts.has_key?(:voiceBridge) ?
78
+ (@req.response[:voiceBridge].should == @req.opts[:voiceBridge]) :
79
+ (@req.response[:voiceBridge].should be_a(Numeric))
80
+
81
+ if @req.opts.has_key?(:meta_one)
82
+ @req.response[:metadata].size.should == 2
83
+ @req.response[:metadata].should be_a(Hash)
84
+ @req.response[:metadata].should include(:one => "one")
85
+ @req.response[:metadata].should include(:two => "TWO")
86
+ else
87
+ @req.response[:metadata].should == {}
88
+ end
89
+
90
+ # note: the duration passed in the api call is not returned (so it won't be checked)
91
+ end
92
+ end
93
+
94
+ Then /^it shows the (\d+) attendees in the list$/ do |count|
95
+ # check only what is different in a meeting that is RUNNING
96
+ # the rest is checked in other scenarios
97
+
98
+ @req.response = @api.get_meeting_info(@req.id, @req.mod_pass)
99
+ participants = count.to_i
100
+
101
+ @req.response[:running].should be_true
102
+ @req.response[:moderatorCount].should > 0
103
+ @req.response[:hasBeenForciblyEnded].should be_false
104
+ @req.response[:participantCount].should == participants
105
+ @req.response[:attendees].size.should == 2
106
+
107
+ # check the start time that should be within 2 hours from now
108
+ @req.response[:startTime].should be_a(DateTime)
109
+ @req.response[:startTime].should < DateTime.now
110
+ @req.response[:startTime].should >= DateTime.now - (2/24.0)
111
+ @req.response[:endTime].should be_nil
112
+
113
+ # in the bot being used, bots are always moderators with these names
114
+ @req.response[:attendees].sort! { |h1,h2| h1[:fullName] <=> h2[:fullName] }
115
+ @req.response[:attendees][0][:fullName].should == "BOT0"
116
+ @req.response[:attendees][0][:role].should == :moderator
117
+ @req.response[:attendees][1][:fullName].should == "BOT1"
118
+ @req.response[:attendees][1][:role].should == :moderator
119
+ end